--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit d3ca433a7c825bd5eb517c3c2b0975f06c9c2390
Parents : bbc067b
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-27T11:16:52-05:00
feat(tests): update fe/be tests and contract api routes
Changes
40 files changed, 2463 insertions(+), 75 deletions(-)
Diff
diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 7f4e7199..b2bbf70d 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -195,6 +195,13 @@ def mock_app(db, tmp_path, temp_db):
new=MagicMock(return_value=None),
),
)
+ stack.enter_context(
+ patch.object(
+ ReticulumMeshChat,
+ "local_message_retention_loop",
+ new=MagicMock(return_value=None),
+ ),
+ )
stack.enter_context(
patch.object(
ReticulumMeshChat,
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 7048996d..910999ef 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -100,6 +100,10 @@
"method": "GET",
"path": "/api/v1/community-interfaces"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/community-interfaces/refresh"
+ },
{
"method": "GET",
"path": "/api/v1/comports"
diff --git a/tests/backend/test_announce_limits.py b/tests/backend/test_announce_limits.py
index 34ecfeaf..59bb9b43 100644
--- a/tests/backend/test_announce_limits.py
+++ b/tests/backend/test_announce_limits.py
@@ -24,6 +24,16 @@ def mock_config():
config.announce_fetch_limit_lxmf_delivery = MagicMock()
config.announce_fetch_limit_nomadnetwork_node = MagicMock()
config.announce_fetch_limit_lxmf_propagation = MagicMock()
+ for _k in (
+ "announce_store_lxmf_delivery",
+ "announce_store_lxst_telephony",
+ "announce_store_nomadnetwork_node",
+ "announce_store_lxmf_propagation",
+ "announce_store_git_repositories",
+ ):
+ _m = MagicMock()
+ _m.get.return_value = True
+ setattr(config, _k, _m)
return config
diff --git a/tests/backend/test_announce_manager_extended.py b/tests/backend/test_announce_manager_extended.py
index f33b3f2d..0420e3fc 100644
--- a/tests/backend/test_announce_manager_extended.py
+++ b/tests/backend/test_announce_manager_extended.py
@@ -44,6 +44,57 @@ def test_upsert_announce(mock_db):
assert data["app_data"] == base64.b64encode(b"app_data").decode("utf-8")
+def test_upsert_skips_when_store_disabled_for_aspect(mock_db):
+ config = MagicMock()
+ config.announce_store_lxmf_delivery = MagicMock()
+ config.announce_store_lxmf_delivery.get.return_value = False
+ manager = AnnounceManager(mock_db, config=config)
+ manager.upsert_announce(
+ None,
+ MagicMock(),
+ b"\x00" * 16,
+ "lxmf.delivery",
+ b"x",
+ None,
+ )
+ mock_db.announces.upsert_announce.assert_not_called()
+
+
+def test_is_storing_announce_for_aspect_respects_config(mock_db):
+ config = MagicMock()
+ config.announce_store_git_repositories = MagicMock()
+ config.announce_store_git_repositories.get.return_value = False
+ manager = AnnounceManager(mock_db, config=config)
+ assert manager.is_storing_announce_for_aspect("git.repositories") is False
+ assert (
+ manager.is_storing_announce_for_aspect("git.repositories", force_store=True)
+ is True
+ )
+ assert manager.is_storing_announce_for_aspect("unknown.aspect") is True
+
+
+def test_upsert_force_store_bypasses_disabled_config(mock_db):
+ config = MagicMock()
+ config.announce_store_nomadnetwork_node = MagicMock()
+ config.announce_store_nomadnetwork_node.get.return_value = False
+ config.announce_max_stored_nomadnetwork_node = MagicMock()
+ config.announce_max_stored_nomadnetwork_node.get.return_value = 1000
+ manager = AnnounceManager(mock_db, config=config)
+ idm = MagicMock()
+ idm.hash.hex.return_value = "a" * 64
+ idm.get_public_key.return_value = b"k"
+ manager.upsert_announce(
+ None,
+ idm,
+ b"\x00" * 16,
+ "nomadnetwork.node",
+ None,
+ None,
+ force_store=True,
+ )
+ mock_db.announces.upsert_announce.assert_called_once()
+
+
def test_get_filtered_announces(mock_db):
manager = AnnounceManager(mock_db)
manager.get_filtered_announces(aspect="test", query="search", limit=10)
diff --git a/tests/backend/test_announce_store_config.py b/tests/backend/test_announce_store_config.py
new file mode 100644
index 00000000..1b61b873
--- /dev/null
+++ b/tests/backend/test_announce_store_config.py
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: 0BSD
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_update_config_sets_announce_store_flags(mock_app):
+ await mock_app.update_config(
+ {
+ "announce_store_lxmf_delivery": False,
+ "announce_store_lxst_telephony": True,
+ "announce_store_nomadnetwork_node": False,
+ "announce_store_lxmf_propagation": True,
+ "announce_store_git_repositories": False,
+ }
+ )
+ c = mock_app.config
+ assert c.announce_store_lxmf_delivery.get() is False
+ assert c.announce_store_lxst_telephony.get() is True
+ assert c.announce_store_nomadnetwork_node.get() is False
+ assert c.announce_store_lxmf_propagation.get() is True
+ assert c.announce_store_git_repositories.get() is False
diff --git a/tests/backend/test_community_interfaces.py b/tests/backend/test_community_interfaces.py
index d6bb31b9..81300d9c 100644
--- a/tests/backend/test_community_interfaces.py
+++ b/tests/backend/test_community_interfaces.py
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
-from unittest.mock import MagicMock
+import json
+from unittest.mock import MagicMock, patch
import pytest
@@ -57,3 +58,94 @@ async def test_community_interfaces_static_list():
ifaces2 = await manager.get_interfaces()
assert ifaces1 == ifaces2
assert all(iface.get("online") is None for iface in ifaces1)
+
+
+@pytest.mark.asyncio
+async def test_community_interfaces_cache_used_when_no_public_override(tmp_path):
+ cache = tmp_path / "community_interfaces_cache.json"
+ cache.write_text(
+ json.dumps(
+ {
+ "interfaces": [
+ {
+ "name": "CacheOnly",
+ "type": "TCPClientInterface",
+ "target_host": "10.0.0.1",
+ "target_port": 4242,
+ },
+ ],
+ },
+ ),
+ encoding="utf-8",
+ )
+ manager = CommunityInterfacesManager(public_override_path=None, cache_path=cache)
+ ifaces = await manager.get_interfaces()
+ assert len(ifaces) == 1
+ assert ifaces[0]["name"] == "CacheOnly"
+
+
+@pytest.mark.asyncio
+async def test_community_interfaces_public_override_beats_cache(tmp_path):
+ public = tmp_path / "public.json"
+ public.write_text(
+ json.dumps(
+ {
+ "interfaces": [
+ {
+ "name": "FromPublic",
+ "type": "TCPClientInterface",
+ "target_host": "10.0.0.2",
+ "target_port": 4242,
+ },
+ ],
+ },
+ ),
+ encoding="utf-8",
+ )
+ cache = tmp_path / "cache.json"
+ cache.write_text(
+ json.dumps(
+ {
+ "interfaces": [
+ {
+ "name": "FromCache",
+ "type": "TCPClientInterface",
+ "target_host": "10.0.0.3",
+ "target_port": 4242,
+ },
+ ],
+ },
+ ),
+ encoding="utf-8",
+ )
+ manager = CommunityInterfacesManager(
+ public_override_path=str(public),
+ cache_path=str(cache),
+ )
+ ifaces = await manager.get_interfaces()
+ assert len(ifaces) == 1
+ assert ifaces[0]["name"] == "FromPublic"
+
+
+def test_refresh_from_directory_writes_cache(tmp_path):
+ fake = [
+ {
+ "name": "FromNet",
+ "type": "TCPClientInterface",
+ "target_host": "9.9.9.9",
+ "target_port": 4242,
+ },
+ ]
+ cache = tmp_path / "community_interfaces_cache.json"
+ manager = CommunityInterfacesManager(public_override_path=None, cache_path=cache)
+ with patch(
+ "meshchatx.src.backend.community_interfaces_directory.build_interfaces_from_directory_url",
+ return_value=(fake, "https://example.test/list"),
+ ) as mock_build:
+ out = manager.refresh_from_directory()
+ mock_build.assert_called_once()
+ assert out["count"] == 1
+ assert out["source"] == "https://example.test/list"
+ assert cache.is_file()
+ manager2 = CommunityInterfacesManager(public_override_path=None, cache_path=cache)
+ assert manager2.interfaces[0]["name"] == "FromNet"
diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py
index 457188ca..2985acec 100644
--- a/tests/backend/test_csp_logic.py
+++ b/tests/backend/test_csp_logic.py
@@ -72,6 +72,72 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
assert m is not None and "blob:" in m.group(1)
+@pytest.mark.asyncio
+async def test_security_middleware_sets_cors_headers_on_rnode_flasher(
+ mock_rns_minimal, tmp_path
+):
+ storage_dir = str(tmp_path / "storage")
+ config_dir = str(tmp_path / "config")
+
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app_instance = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=storage_dir,
+ reticulum_config_dir=config_dir,
+ )
+
+ request = MagicMock(spec=web.Request)
+ request.path = "/rnode-flasher/js/esptool-js@0.4.5/bundle.js"
+ request.app = {}
+
+ async def mock_handler(req):
+ return web.Response(text="// module")
+
+ routes = web.RouteTableDef()
+ _, _, security_middleware = app_instance._define_routes(routes)
+
+ response = await security_middleware(request, mock_handler)
+
+ assert response.headers.get("Access-Control-Allow-Origin") == "*"
+ assert response.headers.get("Cross-Origin-Resource-Policy") == "cross-origin"
+ csp = response.headers.get("Content-Security-Policy", "")
+ m = re.search(r"script-src([^;]+);", csp)
+ assert m is not None and "'unsafe-eval'" in m.group(1)
+
+
+@pytest.mark.asyncio
+async def test_security_middleware_does_not_set_cors_on_reticulum_docs(
+ mock_rns_minimal, tmp_path
+):
+ storage_dir = str(tmp_path / "storage")
+ config_dir = str(tmp_path / "config")
+
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app_instance = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=storage_dir,
+ reticulum_config_dir=config_dir,
+ )
+
+ request = MagicMock(spec=web.Request)
+ request.path = "/reticulum-docs/manual/index.html"
+ request.app = {}
+
+ async def mock_handler(req):
+ return web.Response(text="<html></html>")
+
+ routes = web.RouteTableDef()
+ _, _, security_middleware = app_instance._define_routes(routes)
+
+ response = await security_middleware(request, mock_handler)
+
+ assert response.headers.get("Access-Control-Allow-Origin") is None
+ assert response.headers.get("Cross-Origin-Resource-Policy") is None
+ csp = response.headers.get("Content-Security-Policy", "")
+ m = re.search(r"script-src([^;]+);", csp)
+ assert m is not None and "'unsafe-eval'" not in m.group(1)
+
+
@pytest.mark.asyncio
async def test_config_update_csp(mock_rns_minimal, tmp_path):
storage_dir = str(tmp_path / "storage")
diff --git a/tests/backend/test_interface_discovery.py b/tests/backend/test_interface_discovery.py
index 4a7bb699..9e446941 100644
--- a/tests/backend/test_interface_discovery.py
+++ b/tests/backend/test_interface_discovery.py
@@ -58,6 +58,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
"interface_discovery_blacklist": "tcp-bad,*:9999",
"required_discovery_value": "16",
"autoconnect_discovered_interfaces": "2",
+ "default_bootstrap_only": "yes",
"network_identity": "/tmp/net_id",
},
"interfaces": {},
@@ -105,6 +106,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
)
assert get_data["discovery"]["required_discovery_value"] == "16"
assert get_data["discovery"]["autoconnect_discovered_interfaces"] == "2"
+ assert get_data["discovery"]["default_bootstrap_only"] is True
assert get_data["discovery"]["network_identity"] == "/tmp/net_id"
# PATCH updates and persists
@@ -115,6 +117,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
"interface_discovery_blacklist": "",
"required_discovery_value": 18,
"autoconnect_discovered_interfaces": 5,
+ "default_bootstrap_only": False,
"network_identity": "/tmp/other_id",
}
@@ -134,6 +137,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
assert patch_data["discovery"]["interface_discovery_blacklist"] is None
assert patch_data["discovery"]["required_discovery_value"] == 18
assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 5
+ assert patch_data["discovery"]["default_bootstrap_only"] is False
assert patch_data["discovery"]["network_identity"] == "/tmp/other_id"
assert config["reticulum"]["discover_interfaces"] is False
assert "interface_discovery_sources" not in config["reticulum"]
@@ -141,6 +145,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
assert "interface_discovery_blacklist" not in config["reticulum"]
assert config["reticulum"]["required_discovery_value"] == 18
assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5
+ assert config["reticulum"]["default_bootstrap_only"] is False
assert config["reticulum"]["network_identity"] == "/tmp/other_id"
assert config.write_called
@@ -356,9 +361,129 @@ async def test_interface_add_includes_discovery_fields(temp_dir):
assert saved["discovery_frequency"] == 915000000
assert saved["discovery_bandwidth"] == 125000
assert saved["discovery_modulation"] == "LoRa"
+ assert saved.get("bootstrap_only") == "yes"
assert config.write_called
+@pytest.mark.asyncio
+async def test_interface_add_tcp_omits_bootstrap_when_default_off(temp_dir):
+ config = ConfigDict(
+ {
+ "reticulum": {"default_bootstrap_only": False},
+ "interfaces": {},
+ },
+ )
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.transport_enabled.return_value = True
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+ add_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/interfaces/add",
+ "POST",
+ )
+ assert add_handler
+
+ payload = {
+ "allow_overwriting_interface": False,
+ "name": "NoBoot",
+ "type": "TCPClientInterface",
+ "target_host": "example.com",
+ "target_port": "4242",
+ }
+
+ class AddRequest:
+ @staticmethod
+ async def json():
+ return payload
+
+ response = await add_handler(AddRequest())
+ data = json.loads(response.body)
+ assert "Interface has been added" in data["message"]
+ saved = config["interfaces"]["NoBoot"]
+ assert "bootstrap_only" not in saved
+
+
+@pytest.mark.asyncio
+async def test_interface_add_tcp_explicit_bootstrap_only_no(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.transport_enabled.return_value = True
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+ add_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/interfaces/add",
+ "POST",
+ )
+ assert add_handler
+
+ payload = {
+ "allow_overwriting_interface": False,
+ "name": "ExplicitNo",
+ "type": "TCPClientInterface",
+ "target_host": "example.com",
+ "target_port": "4242",
+ "bootstrap_only": False,
+ }
+
+ class AddRequest:
+ @staticmethod
+ async def json():
+ return payload
+
+ response = await add_handler(AddRequest())
+ data = json.loads(response.body)
+ assert "Interface has been added" in data["message"]
+ assert config["interfaces"]["ExplicitNo"]["bootstrap_only"] == "no"
+
+
+def test_apply_bootstrap_only_to_interface():
+ details = {}
+ ReticulumMeshChat.apply_bootstrap_only_to_interface(details, {}, True)
+ assert details["bootstrap_only"] == "yes"
+
+ details = {"bootstrap_only": "yes"}
+ ReticulumMeshChat.apply_bootstrap_only_to_interface(
+ details, {"bootstrap_only": False}, True
+ )
+ assert details["bootstrap_only"] == "no"
+
+ details = {}
+ ReticulumMeshChat.apply_bootstrap_only_to_interface(details, {}, False)
+ assert "bootstrap_only" not in details
+
+
@pytest.mark.asyncio
async def test_interface_add_discoverable_without_optional_coordinates(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
diff --git a/tests/backend/test_local_message_retention.py b/tests/backend/test_local_message_retention.py
new file mode 100644
index 00000000..1ef32bd3
--- /dev/null
+++ b/tests/backend/test_local_message_retention.py
@@ -0,0 +1,219 @@
+# SPDX-License-Identifier: 0BSD
+
+import time
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshchatx.src.backend import local_message_retention as lmr
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.database.schema import DatabaseSchema
+
+
+def test_normalize_unit():
+ assert lmr.normalize_unit("DAYS") == lmr.UNIT_DAYS
+ assert lmr.normalize_unit("Month") == lmr.UNIT_MONTHS
+ assert lmr.normalize_unit("m") == lmr.UNIT_MONTHS
+ assert lmr.normalize_unit(None) == lmr.UNIT_DAYS
+
+
+def test_retention_window_seconds():
+ assert lmr.retention_window_seconds(1, "days") == 86400
+ assert lmr.retention_window_seconds(2, "months") == 2 * 30 * 86400
+ assert lmr.retention_window_seconds(20000, "days") == lmr.MAX_VALUE_DAYS * 86400
+ assert (
+ lmr.retention_window_seconds(200, "months") == lmr.MAX_VALUE_MONTHS * 30 * 86400
+ )
+
+
+def test_local_message_retention_cutoff_ts():
+ now = 1_000_000.0
+ c = lmr.local_message_retention_cutoff_ts(now, 1, "days")
+ assert c == now - 86400.0
+
+
+@pytest.fixture
+def _db_path(tmp_path):
+ return str(tmp_path / "t.db")
+
+
+def test_apply_deletes_and_prunes(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ now = time.time()
+ old_ts = now - 10 * 86400
+ new_ts = now - 86400
+ peer = "a" * 32
+ base = {
+ "source_hash": peer,
+ "destination_hash": peer,
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": 1,
+ "method": "ephemeral",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "t",
+ "content": "c",
+ "fields": None,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ }
+ db.messages.upsert_lxmf_message(
+ {**base, "hash": "a" * 32, "timestamp": old_ts},
+ )
+ db.messages.upsert_lxmf_message(
+ {**base, "hash": "b" * 32, "timestamp": new_ts},
+ )
+ db.provider.execute(
+ "INSERT INTO lxmf_conversation_read_state (destination_hash) VALUES (?)",
+ (peer,),
+ )
+ n = lmr.apply_local_message_retention(
+ db.messages,
+ None,
+ value=2,
+ unit=lmr.UNIT_DAYS,
+ now=now,
+ )
+ assert n == 1
+ assert db.messages.count_lxmf_messages() == 1
+ left = db.provider.fetchone(
+ "SELECT 1 AS ok FROM lxmf_messages WHERE hash = ?", ("b" * 32,)
+ )
+ assert left is not None
+ rs = db.provider.fetchall(
+ "SELECT 1 AS ok FROM lxmf_conversation_read_state WHERE destination_hash = ?",
+ (peer,),
+ )
+ assert len(rs) == 1
+ db.close_all()
+ provider.close_all()
+
+
+def test_prune_clears_read_state_when_conversation_empty(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ now = time.time()
+ peer = "c" * 32
+ h = "d" * 32
+ old_ts = now - 3 * 86400
+ db.messages.upsert_lxmf_message(
+ {
+ "hash": h,
+ "source_hash": peer,
+ "destination_hash": peer,
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": 1,
+ "method": "ephemeral",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "t",
+ "content": "c",
+ "fields": None,
+ "timestamp": old_ts,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ },
+ )
+ db.provider.execute(
+ "INSERT INTO lxmf_conversation_read_state (destination_hash) VALUES (?)",
+ (peer,),
+ )
+ lmr.apply_local_message_retention(
+ db.messages,
+ None,
+ value=1,
+ unit=lmr.UNIT_DAYS,
+ now=now,
+ )
+ assert db.messages.count_lxmf_messages() == 0
+ assert (
+ len(
+ db.provider.fetchall(
+ "SELECT 1 AS x FROM lxmf_conversation_read_state WHERE destination_hash = ?",
+ (peer,),
+ )
+ )
+ == 0
+ )
+ db.close_all()
+ provider.close_all()
+
+
+async def test_config_patch_local_message_retention_keys(mock_app):
+ await mock_app.update_config(
+ {
+ "local_message_auto_delete_enabled": True,
+ "local_message_auto_delete_value": 3,
+ "local_message_auto_delete_unit": "months",
+ },
+ )
+ assert mock_app.config.local_message_auto_delete_enabled.get() is True
+ assert mock_app.config.local_message_auto_delete_value.get() == 3
+ assert mock_app.config.local_message_auto_delete_unit.get() == "months"
+ await mock_app.update_config(
+ {
+ "local_message_auto_delete_value": 9999,
+ "local_message_auto_delete_unit": "months",
+ },
+ )
+ assert mock_app.config.local_message_auto_delete_value.get() == lmr.MAX_VALUE_MONTHS
+
+
+def test_apply_calls_cancel_for_hex_hashes(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ now = time.time()
+ h = "aa" * 16
+ db.messages.upsert_lxmf_message(
+ {
+ "hash": h,
+ "source_hash": h,
+ "destination_hash": h,
+ "peer_hash": h,
+ "state": "sending",
+ "progress": 0.0,
+ "is_incoming": 0,
+ "method": "opportunistic",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": "x",
+ "fields": None,
+ "timestamp": now - 5 * 86400,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ },
+ )
+ cancel = MagicMock()
+ lmr.apply_local_message_retention(
+ db.messages,
+ cancel,
+ value=1,
+ unit="days",
+ now=now,
+ )
+ cancel.assert_called()
+ assert db.messages.count_lxmf_messages() == 0
+ db.close_all()
+ provider.close_all()
diff --git a/tests/backend/test_lxmf_propagation_full.py b/tests/backend/test_lxmf_propagation_full.py
index 7db609a2..9147626f 100644
--- a/tests/backend/test_lxmf_propagation_full.py
+++ b/tests/backend/test_lxmf_propagation_full.py
@@ -352,6 +352,8 @@ async def test_destination_path_returns_local_hop_zero_for_local_destinations(mo
assert data["path"]["hops"] == 0
assert data["path"]["next_hop"] == local_hash
assert data["path"]["next_hop_interface"] == "Local"
+ assert data["path_stale"] is False
+ assert data["path_unresponsive"] is False
def test_convert_propagation_node_state_maps_all_lxmf_transfer_states():
diff --git a/tests/backend/test_lxmf_utils_extended.py b/tests/backend/test_lxmf_utils_extended.py
index 24f39c78..43aca007 100644
--- a/tests/backend/test_lxmf_utils_extended.py
+++ b/tests/backend/test_lxmf_utils_extended.py
@@ -144,6 +144,8 @@ def test_convert_db_lxmf_message_to_dict():
"snr": 5,
"quality": 2,
"is_spam": 0,
+ "path_hops_at_send": 4,
+ "path_interface_at_send": "RNode Interface",
"created_at": "2023-01-01 12:00:00",
"updated_at": "2023-01-01 12:05:00",
}
@@ -152,6 +154,8 @@ def test_convert_db_lxmf_message_to_dict():
result = convert_db_lxmf_message_to_dict(db_msg, include_attachments=True)
assert result["fields"]["image"]["image_bytes"] is not None
assert result["created_at"].endswith("Z")
+ assert result["path_hops_at_send"] == 4
+ assert result["path_interface_at_send"] == "RNode Interface"
# Test without attachments
result_no_att = convert_db_lxmf_message_to_dict(db_msg, include_attachments=False)
@@ -161,6 +165,32 @@ def test_convert_db_lxmf_message_to_dict():
assert result_no_att["fields"]["file_attachments"][0]["file_size"] == len(b"file")
+def test_convert_db_lxmf_message_to_dict_defaults_missing_method():
+ db_msg = {
+ "id": 1,
+ "hash": "a" * 32,
+ "source_hash": "b" * 32,
+ "destination_hash": "c" * 32,
+ "is_incoming": 0,
+ "state": "sent",
+ "progress": 100.0,
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": "x",
+ "fields": "{}",
+ "timestamp": 1.0,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "created_at": "2023-01-01 12:00:00",
+ "updated_at": "2023-01-01 12:00:00",
+ }
+ result = convert_db_lxmf_message_to_dict(db_msg)
+ assert result["method"] == "unknown"
+
+
def test_convert_lxmf_message_to_dict_with_reply():
mock_msg = MagicMock(spec=LXMF.LXMessage)
mock_msg.hash = b"msg_hash"
diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py
index 68bcb5c1..5ca2ebd7 100644
--- a/tests/backend/test_meshchat_coverage.py
+++ b/tests/backend/test_meshchat_coverage.py
@@ -10,6 +10,7 @@ import pytest
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.lxmf_message_fields import LxmfAudioField
+from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome
@pytest.fixture
@@ -145,7 +146,8 @@ def test_get_config_dict_basic(mock_app):
"banished_color",
"message_font_size",
"message_icon_size",
- "translator_enabled",
+ "translator_argos_enabled",
+ "translator_libretranslate_enabled",
"libretranslate_url",
"desktop_open_calls_in_separate_window",
"desktop_hardware_acceleration_enabled",
@@ -528,7 +530,9 @@ def sendable_app(mock_app):
ctx.local_lxmf_destination = MagicMock()
ctx.forwarding_manager = None
- mock_app._await_transport_path = AsyncMock()
+ mock_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(True, "reused_valid_path", False),
+ )
mock_app.get_current_icon_hash = MagicMock(return_value=None)
mock_app.db_upsert_lxmf_message = MagicMock()
mock_app.websocket_broadcast = AsyncMock()
diff --git a/tests/backend/test_message_dao_extended.py b/tests/backend/test_message_dao_extended.py
index 315f0549..c307091c 100644
--- a/tests/backend/test_message_dao_extended.py
+++ b/tests/backend/test_message_dao_extended.py
@@ -56,3 +56,14 @@ def test_get_conversation_messages(message_dao, mock_provider):
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
("peer1", 10, 5),
)
+
+
+def test_set_lxmf_message_path_at_send_if_unset(message_dao, mock_provider):
+ message_dao.set_lxmf_message_path_at_send_if_unset("deadbeef", 2, "UDP Interface")
+ args, _ = mock_provider.execute.call_args
+ query, params = args
+ assert "path_hops_at_send" in query
+ assert "path_hops_at_send IS NULL" in query
+ assert params[0] == 2
+ assert params[1] == "UDP Interface"
+ assert params[3] == "deadbeef"
diff --git a/tests/backend/test_message_sending_failures.py b/tests/backend/test_message_sending_failures.py
index 8d703900..33ee3b43 100644
--- a/tests/backend/test_message_sending_failures.py
+++ b/tests/backend/test_message_sending_failures.py
@@ -8,6 +8,7 @@ import LXMF
import pytest
from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome
@pytest.fixture
@@ -19,7 +20,9 @@ def mock_app():
app.database = MagicMock()
app.reticulum = MagicMock()
app.message_router = MagicMock()
- app._await_transport_path = AsyncMock(return_value=True)
+ app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(True, "reused_valid_path", False),
+ )
app.get_current_icon_hash = MagicMock(return_value=None)
app.db_upsert_lxmf_message = MagicMock()
app.websocket_broadcast = AsyncMock()
@@ -235,7 +238,9 @@ async def test_send_message_db_upsert_failure_still_broadcasts(mock_app):
@pytest.mark.asyncio
async def test_send_message_await_path_timeout(mock_app):
- mock_app._await_transport_path = AsyncMock(return_value=False)
+ mock_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
destination_hash = "aa" * 16
# Even if _await_transport_path returns False, it continues to recall identity
diff --git a/tests/backend/test_reticulum_pathfinding.py b/tests/backend/test_reticulum_pathfinding.py
new file mode 100644
index 00000000..52aeb5c9
--- /dev/null
+++ b/tests/backend/test_reticulum_pathfinding.py
@@ -0,0 +1,358 @@
+# SPDX-License-Identifier: 0BSD
+
+import time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import RNS
+
+from meshchatx.src.backend import reticulum_pathfinding as rp
+from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome
+
+DEST = bytes(16)
+
+
+def _put_path_entry(dest, entry, cleanup: list) -> None:
+ with RNS.Transport.path_table_lock:
+ RNS.Transport.path_table[dest] = entry
+ cleanup.append(dest)
+
+
+def test_should_rediscover_when_no_path():
+ with patch.object(RNS.Transport, "has_path", return_value=False):
+ assert rp.should_rediscover_path(DEST) is True
+
+
+def test_should_rediscover_when_unresponsive():
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ ):
+ assert rp.should_rediscover_path(DEST) is True
+
+
+def test_should_rediscover_when_expired():
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=True,
+ ),
+ ):
+ assert rp.should_rediscover_path(DEST) is True
+
+
+def test_no_rediscover_when_fresh():
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ ):
+ assert rp.should_rediscover_path(DEST) is False
+
+
+def test_path_metadata_no_path():
+ with patch.object(RNS.Transport, "has_path", return_value=False):
+ m = rp.path_metadata_for_api(DEST)
+ assert m["path_stale"] is True
+ assert m["path_unresponsive"] is False
+
+
+def test_path_table_entry_expired_ap_mode():
+ iface = MagicMock()
+ iface.mode = RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT
+ entry = [
+ time.time() - RNS.Transport.AP_PATH_TIME - 5,
+ None,
+ 1,
+ 0,
+ None,
+ iface,
+ None,
+ ]
+ assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True
+
+
+def test_path_table_entry_fresh_ap_mode():
+ iface = MagicMock()
+ iface.mode = RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT
+ entry = [time.time(), None, 1, 0, None, iface, None]
+ assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is False
+
+
+def test_prepare_fresh_drops_and_requests_when_stale():
+ r = MagicMock()
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path",
+ return_value=True,
+ ),
+ patch.object(RNS.Transport, "request_path") as mock_req,
+ ):
+ assert rp.prepare_fresh_path_request(r, DEST) == "path_refresh_requested"
+ r.drop_path.assert_called_once_with(DEST)
+ mock_req.assert_called_once_with(DEST)
+
+
+def test_prepare_fresh_noop_when_reusing():
+ r = MagicMock()
+ with (
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path",
+ return_value=False,
+ ),
+ patch.object(RNS.Transport, "request_path") as mock_req,
+ ):
+ assert rp.prepare_fresh_path_request(r, DEST) == "reused_valid_path"
+ r.drop_path.assert_not_called()
+ mock_req.assert_not_called()
+
+
+def test_path_table_entry_expired_roaming_mode():
+ iface = MagicMock()
+ iface.mode = RNS.Interfaces.Interface.Interface.MODE_ROAMING
+ entry = [
+ time.time() - RNS.Transport.ROAMING_PATH_TIME - 5,
+ None,
+ 1,
+ 0,
+ None,
+ iface,
+ None,
+ ]
+ assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True
+
+
+def test_path_table_entry_fresh_roaming_mode():
+ iface = MagicMock()
+ iface.mode = RNS.Interfaces.Interface.Interface.MODE_ROAMING
+ entry = [time.time(), None, 1, 0, None, iface, None]
+ assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is False
+
+
+def test_path_table_entry_expired_default_mode():
+ iface = MagicMock()
+ iface.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
+ old = time.time() - RNS.Transport.DESTINATION_TIMEOUT - 1
+ entry = [old, None, 1, 0, None, iface, None]
+ assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True
+
+
+def test_path_table_entry_expired_when_rvcd_if_is_none():
+ old = time.time() - RNS.Transport.DESTINATION_TIMEOUT - 1
+ entry = [old, None, 1, 0, None, None, None]
+ assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True
+
+
+def test_transport_path_table_entry_is_expired_uses_path_table():
+ to_del = []
+ dest = bytes([0x1B] * 16)
+ iface = MagicMock()
+ iface.mode = RNS.Interfaces.Interface.Interface.MODE_ROAMING
+ _put_path_entry(
+ dest,
+ [
+ time.time() - RNS.Transport.ROAMING_PATH_TIME - 2,
+ None,
+ 1,
+ 0,
+ None,
+ iface,
+ None,
+ ],
+ to_del,
+ )
+ try:
+ assert rp.transport_path_table_entry_is_expired(dest) is True
+ finally:
+ with RNS.Transport.path_table_lock:
+ for d in to_del:
+ RNS.Transport.path_table.pop(d, None)
+
+
+def test_path_metadata_when_has_path_stale():
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=True,
+ ),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ ):
+ m = rp.path_metadata_for_api(DEST)
+ assert m == {"path_stale": True, "path_unresponsive": False}
+
+
+def test_path_metadata_when_unresponsive():
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
+ ):
+ m = rp.path_metadata_for_api(DEST)
+ assert m == {"path_stale": False, "path_unresponsive": True}
+
+
+def test_prepare_fresh_uses_expire_path_without_reticulum():
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path",
+ return_value=True,
+ ),
+ patch.object(RNS.Transport, "expire_path") as ex,
+ patch.object(RNS.Transport, "request_path") as req,
+ ):
+ assert rp.prepare_fresh_path_request(None, DEST) == "path_refresh_requested"
+ ex.assert_called_once_with(DEST)
+ req.assert_called_once_with(DEST)
+
+
+def test_lxmf_path_wait_cap_uses_rns_default():
+ v = rp.lxmf_path_wait_cap_seconds()
+ assert 30.0 <= v <= 120.0
+
+
+def test_lxmf_path_wait_cap_falls_back_when_float_fails():
+ with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"):
+ assert rp.lxmf_path_wait_cap_seconds() == 30.0
+
+
+def test_nudge_path_request_forwards_to_transport():
+ with patch.object(RNS.Transport, "request_path") as m:
+ rp.nudge_path_request(DEST)
+ m.assert_called_once_with(DEST)
+
+
+@pytest.mark.asyncio
+async def test_meshchat_await_transport_path_delegates_to_module():
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ outcome = OutboundPathOutcome(True, "reused_valid_path", False)
+ with patch(
+ "meshchatx.meshchat.reticulum_pathfinding.await_transport_path_for_outbound_lxmf",
+ new=AsyncMock(return_value=outcome),
+ ) as m:
+ inst = object.__new__(ReticulumMeshChat)
+ inst.reticulum = object()
+ r = await ReticulumMeshChat._await_transport_path(inst, DEST)
+ assert r.path_available is True
+ m.assert_called_once_with(inst.reticulum, DEST)
+
+
+@pytest.mark.asyncio
+async def test_await_outbound_lxmf_returns_true_when_path_immediate():
+ with (
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request",
+ return_value="reused_valid_path",
+ ) as prep,
+ patch.object(
+ RNS.Transport,
+ "has_path",
+ return_value=True,
+ ),
+ ):
+ out = await rp.await_transport_path_for_outbound_lxmf(MagicMock(), DEST)
+ assert out.path_available is True
+ assert out.prepare_measure == "reused_valid_path"
+ assert out.used_nudge is False
+ prep.assert_called_once()
+
+
+def test_format_outbound_path_finding_measure_appends_nudge():
+ o = OutboundPathOutcome(True, "new_path_requested", True)
+ assert rp.format_outbound_path_finding_measure(o) == "new_path_requested+nudge"
+ o2 = OutboundPathOutcome(True, "reused_valid_path", False)
+ assert rp.format_outbound_path_finding_measure(o2) == "reused_valid_path"
+
+
+def test_prepare_fresh_requests_when_no_path_but_rediscover():
+ r = MagicMock()
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=False),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path",
+ return_value=True,
+ ),
+ patch.object(RNS.Transport, "request_path") as mock_req,
+ ):
+ assert rp.prepare_fresh_path_request(r, DEST) == "new_path_requested"
+ r.drop_path.assert_not_called()
+ mock_req.assert_called_once_with(DEST)
+
+
+@pytest.mark.asyncio
+async def test_await_outbound_lxmf_returns_false_after_waits_exhausted():
+ nudge = MagicMock()
+ with (
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request",
+ ),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.lxmf_path_wait_cap_seconds",
+ return_value=0.0,
+ ),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.nudge_path_request",
+ nudge,
+ ),
+ patch.object(RNS.Transport, "has_path", return_value=False),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.asyncio.sleep",
+ new_callable=AsyncMock,
+ ),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.time.time",
+ side_effect=[0.0, 0.0, 0.0, 0.0, 100.0],
+ ),
+ ):
+ out = await rp.await_transport_path_for_outbound_lxmf(MagicMock(), DEST)
+ assert out.path_available is False
+ assert out.used_nudge is True
+ nudge.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_wait_for_path_returns_true_immediately():
+ with (
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request",
+ ) as p,
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ ):
+ ok = await rp.wait_for_path(MagicMock(), DEST, 5.0, 0.01)
+ assert ok is True
+ p.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_wait_for_path_times_out():
+ with (
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request",
+ ),
+ patch.object(RNS.Transport, "has_path", return_value=False),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.asyncio.sleep",
+ new_callable=AsyncMock,
+ ),
+ patch(
+ "meshchatx.src.backend.reticulum_pathfinding.time.monotonic",
+ side_effect=[0.0, 0.0, 0.02],
+ ),
+ ):
+ ok = await rp.wait_for_path(MagicMock(), DEST, 0.01, 0.01)
+ assert ok is False
diff --git a/tests/backend/test_reticulum_pathfinding_http.py b/tests/backend/test_reticulum_pathfinding_http.py
new file mode 100644
index 00000000..0cc07559
--- /dev/null
+++ b/tests/backend/test_reticulum_pathfinding_http.py
@@ -0,0 +1,66 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
+ mock_app.reticulum = MagicMock()
+ mock_app.reticulum.get_next_hop.return_value = bytes(16)
+ mock_app.reticulum.get_next_hop_if_name.return_value = "if0"
+ h = next(
+ r.handler
+ for r in mock_app.get_routes()
+ if r.path == "/api/v1/destination/{destination_hash}/path"
+ )
+ dest = "a" * 32
+ req = SimpleNamespace(
+ match_info={"destination_hash": dest},
+ query={"request": "1", "timeout": "1"},
+ )
+ with (
+ patch(
+ "meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request",
+ ) as pfp,
+ patch(
+ "meshchatx.meshchat.reticulum_pathfinding.path_metadata_for_api",
+ return_value={"path_stale": False, "path_unresponsive": False},
+ ),
+ patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
+ patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=2),
+ patch("meshchatx.meshchat.asyncio.sleep", new_callable=AsyncMock),
+ ):
+ response = await h(req)
+ pfp.assert_called_once()
+ assert pfp.call_args[0][0] is mock_app.reticulum
+ assert pfp.call_args[0][1] == bytes.fromhex(dest)
+ data = json.loads(response.body)
+ assert data["path"]["hops"] == 2
+ assert data["path_stale"] is False
+ assert data["path_unresponsive"] is False
+
+
+@pytest.mark.asyncio
+async def test_post_destination_request_path_calls_prepare_fresh(mock_app):
+ mock_app.reticulum = MagicMock()
+ h = next(
+ r.handler
+ for r in mock_app.get_routes()
+ if r.path == "/api/v1/destination/{destination_hash}/request-path"
+ )
+ dest = "b" * 32
+ req = SimpleNamespace(match_info={"destination_hash": dest})
+ with patch(
+ "meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request",
+ ) as pfp:
+ response = await h(req)
+ pfp.assert_called_once()
+ assert pfp.call_args[0][0] is mock_app.reticulum
+ assert pfp.call_args[0][1] == bytes.fromhex(dest)
+ assert response.status == 200
+ data = json.loads(response.body)
+ assert data["message"] == "ok"
diff --git a/tests/backend/test_rnode_download_firmware.py b/tests/backend/test_rnode_download_firmware.py
index ccf3a00d..93b286fc 100644
--- a/tests/backend/test_rnode_download_firmware.py
+++ b/tests/backend/test_rnode_download_firmware.py
@@ -50,7 +50,7 @@ class _FakeSession:
async def __aexit__(self, exc_type, exc, tb):
return False
- def get(self, url, allow_redirects=True):
+ def get(self, url, allow_redirects=True, headers=None):
self.requested_urls.append(url)
status = self._status
body = self._body
@@ -172,6 +172,7 @@ class _FakeJsonSession:
self._status = status
self._payload = payload
self.requested_urls: list[str] = []
+ self.last_headers = None
async def __aenter__(self):
return self
@@ -179,8 +180,9 @@ class _FakeJsonSession:
async def __aexit__(self, exc_type, exc, tb):
return False
- def get(self, url, allow_redirects=True):
+ def get(self, url, allow_redirects=True, headers=None):
self.requested_urls.append(url)
+ self.last_headers = headers
status = self._status
payload = self._payload
@@ -191,6 +193,64 @@ class _FakeJsonSession:
return _cm()
+@pytest.mark.asyncio
+async def test_download_firmware_accepts_objects_githubusercontent_url(web_app):
+ aio_app = _build_aio_app(web_app)
+ fake_zip = b"PK\x03\x04x"
+ fake_session = _FakeSession(200, fake_zip)
+ asset_url = (
+ "https://objects.githubusercontent.com/github-production-release-asset/1/2/3"
+ "?response-content-disposition=attachment%3B%20filename%3Dfw.zip"
+ )
+
+ with patch("aiohttp.ClientSession", MagicMock(return_value=fake_session)):
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.get(
+ "/api/v1/tools/rnode/download_firmware",
+ params={"url": asset_url},
+ )
+ assert r.status == 200
+ assert fake_session.requested_urls == [asset_url]
+
+
+@pytest.mark.asyncio
+async def test_download_firmware_accepts_release_assets_githubusercontent_url(web_app):
+ aio_app = _build_aio_app(web_app)
+ fake_zip = b"PK\x03\x04y"
+ fake_session = _FakeSession(200, fake_zip)
+ asset_url = "https://release-assets.githubusercontent.com/github-production-release-asset/9/8/7/fw.zip"
+
+ with patch("aiohttp.ClientSession", MagicMock(return_value=fake_session)):
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.get(
+ "/api/v1/tools/rnode/download_firmware",
+ params={"url": asset_url},
+ )
+ assert r.status == 200
+ data = await r.read()
+ assert data == fake_zip
+
+
+@pytest.mark.asyncio
+async def test_download_firmware_accepts_configured_gitea_base_url(web_app):
+ aio_app = _build_aio_app(web_app)
+ web_app.config.gitea_base_url.set("https://gitea.custom.example")
+ fake_zip = b"PK\x03\x04z"
+ fake_session = _FakeSession(200, fake_zip)
+ asset_url = (
+ "https://gitea.custom.example/someorg/somerepo/releases/download/v1/x.zip"
+ )
+
+ with patch("aiohttp.ClientSession", MagicMock(return_value=fake_session)):
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.get(
+ "/api/v1/tools/rnode/download_firmware",
+ params={"url": asset_url},
+ )
+ assert r.status == 200
+ assert fake_session.requested_urls == [asset_url]
+
+
@pytest.mark.asyncio
async def test_latest_release_returns_proxied_payload(web_app):
aio_app = _build_aio_app(web_app)
@@ -214,8 +274,16 @@ async def test_latest_release_returns_proxied_payload(web_app):
assert r.status == 200
body = await r.json()
assert body == payload
- assert fake_session.requested_urls[0].endswith(
- "/api/v1/repos/Reticulum/RNode_Firmware/releases/latest"
+ assert fake_session.requested_urls[0] == (
+ "https://api.github.com/repos/markqvist/RNode_Firmware/releases/latest"
+ )
+ assert fake_session.last_headers is not None
+ assert (
+ fake_session.last_headers.get("Accept") == "application/vnd.github+json"
+ )
+ assert fake_session.last_headers.get("X-GitHub-Api-Version") == "2022-11-28"
+ assert "MeshChatX-RNodeFlasher" in fake_session.last_headers.get(
+ "User-Agent", ""
)
@@ -234,8 +302,8 @@ async def test_latest_release_uses_repo_query_param(web_app):
params={"repo": "Some/Other_Repo"},
)
assert r.status == 200
- assert fake_session.requested_urls[0].endswith(
- "/api/v1/repos/Some/Other_Repo/releases/latest"
+ assert fake_session.requested_urls[0] == (
+ "https://api.github.com/repos/Some/Other_Repo/releases/latest"
)
@@ -243,7 +311,16 @@ async def test_latest_release_uses_repo_query_param(web_app):
async def test_latest_release_rejects_invalid_repo(web_app):
aio_app = _build_aio_app(web_app)
async with TestClient(TestServer(aio_app)) as client:
- for repo in ("no-slash", "../etc/passwd", "evil repo/x", "bad?repo/x"):
+ for repo in (
+ "no-slash",
+ "../etc/passwd",
+ "evil repo/x",
+ "bad?repo/x",
+ "too/many/slashes",
+ "@bad/name",
+ "/leading/slash",
+ "trailing/",
+ ):
r = await client.get(
"/api/v1/tools/rnode/latest_release",
params={"repo": repo},
diff --git a/tests/backend/test_startup.py b/tests/backend/test_startup.py
index b44b46a0..286a023e 100644
--- a/tests/backend/test_startup.py
+++ b/tests/backend/test_startup.py
@@ -126,7 +126,8 @@ def test_reticulum_meshchat_init(mock_rns, temp_dir):
mock_config_instance.libretranslate_url.get.return_value = (
"http://localhost:5000"
)
- mock_config_instance.translator_enabled.get.return_value = False
+ mock_config_instance.translator_argos_enabled.get.return_value = False
+ mock_config_instance.translator_libretranslate_enabled.get.return_value = False
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
diff --git a/tests/backend/test_startup_advanced.py b/tests/backend/test_startup_advanced.py
index 95ef0adf..6b58b1c2 100644
--- a/tests/backend/test_startup_advanced.py
+++ b/tests/backend/test_startup_advanced.py
@@ -115,7 +115,8 @@ def test_run_https_logic(mock_rns, temp_dir):
mock_config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
mock_config.lxmf_local_propagation_node_enabled.get.return_value = False
mock_config.libretranslate_url.get.return_value = "http://localhost:5000"
- mock_config.translator_enabled.get.return_value = False
+ mock_config.translator_argos_enabled.get.return_value = False
+ mock_config.translator_libretranslate_enabled.get.return_value = False
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
@@ -275,7 +276,8 @@ def test_database_health_issues_set_on_setup(mock_rns, temp_dir):
mock_config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
mock_config.lxmf_local_propagation_node_enabled.get.return_value = False
mock_config.libretranslate_url.get.return_value = "http://localhost:5000"
- mock_config.translator_enabled.get.return_value = False
+ mock_config.translator_argos_enabled.get.return_value = False
+ mock_config.translator_libretranslate_enabled.get.return_value = False
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
diff --git a/tests/backend/test_translator_argos_integration.py b/tests/backend/test_translator_argos_integration.py
index a4307f7a..0e468d74 100644
--- a/tests/backend/test_translator_argos_integration.py
+++ b/tests/backend/test_translator_argos_integration.py
@@ -59,7 +59,10 @@ def test_find_argos_cli_matches_shutil_which():
reason="Network unreachable (Argos CLI may download Stanza resources)",
)
def test_translate_en_es_via_cli_round_trip():
- handler = TranslatorHandler(enabled=True)
+ handler = TranslatorHandler(
+ translator_argos_enabled=True,
+ translator_libretranslate_enabled=True,
+ )
assert handler.has_argos_cli
assert not handler.has_argos_lib
@@ -89,7 +92,10 @@ def test_translate_en_es_via_cli_round_trip():
@pytest.mark.integration
@pytest.mark.skipif(not _argos_cli_on_path(), reason="Argos CLI not on PATH")
def test_get_supported_languages_includes_argos_when_libretranslate_down():
- handler = TranslatorHandler(enabled=True)
+ handler = TranslatorHandler(
+ translator_argos_enabled=True,
+ translator_libretranslate_enabled=True,
+ )
langs = handler.get_supported_languages()
argos = [x for x in langs if x.get("source") == "argos"]
assert len(argos) >= 1
diff --git a/tests/backend/test_translator_config_migration.py b/tests/backend/test_translator_config_migration.py
new file mode 100644
index 00000000..1f452b0c
--- /dev/null
+++ b/tests/backend/test_translator_config_migration.py
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: 0BSD
+
+import os
+import tempfile
+
+from meshchatx.src.backend.config_manager import ConfigManager
+from meshchatx.src.backend.database import Database
+
+
+def test_migrates_legacy_translator_enabled_to_per_backend_keys():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ db_path = os.path.join(tmpdir, "t.db")
+ db = Database(db_path)
+ db.initialize()
+ db.config.set("translator_enabled", "true")
+ config = ConfigManager(db)
+ assert config.translator_argos_enabled.get() is True
+ assert config.translator_libretranslate_enabled.get() is True
diff --git a/tests/backend/test_translator_handler.py b/tests/backend/test_translator_handler.py
index 28fd0543..2f360579 100644
--- a/tests/backend/test_translator_handler.py
+++ b/tests/backend/test_translator_handler.py
@@ -27,11 +27,17 @@ def _mock_session_for_languages():
class TestTranslatorHandler(unittest.TestCase):
def setUp(self):
- self.handler = TranslatorHandler(enabled=True)
+ self.handler = TranslatorHandler(
+ translator_libretranslate_enabled=True,
+ translator_argos_enabled=False,
+ )
@patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession")
def test_get_supported_languages(self, mock_session_cls):
self.handler.has_requests = True
+ self.handler.has_argos = False
+ self.handler.has_argos_lib = False
+ self.handler.has_argos_cli = False
mock_session_cls.return_value = _mock_session_for_languages()
langs = self.handler.get_supported_languages()
@@ -41,6 +47,7 @@ class TestTranslatorHandler(unittest.TestCase):
@patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession")
def test_translate_text_libretranslate(self, mock_session_cls):
self.handler.has_requests = True
+ self.handler.translator_libretranslate_enabled = True
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(
@@ -58,7 +65,7 @@ class TestTranslatorHandler(unittest.TestCase):
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session_cls.return_value = mock_session
- result = self.handler.translate_text("Hello", "en", "de")
+ result = self.handler.translate_text("Hello", "en", "de", use_argos=False)
self.assertEqual(result["translated_text"], "Hallo")
self.assertEqual(result["source"], "libretranslate")
diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py
index e1557f2d..d11ca76d 100644
--- a/tests/backend/test_translator_handler_extended.py
+++ b/tests/backend/test_translator_handler_extended.py
@@ -8,13 +8,21 @@ from meshchatx.src.backend.translator_handler import TranslatorHandler
def test_translator_handler_init():
- handler = TranslatorHandler(libretranslate_url="http://test:5000", enabled=True)
+ handler = TranslatorHandler(
+ libretranslate_url="http://test:5000",
+ translator_argos_enabled=True,
+ translator_libretranslate_enabled=True,
+ )
assert handler.libretranslate_url == "http://test:5000"
- assert handler.enabled is True
+ assert handler.translator_argos_enabled is True
-def test_get_supported_languages_disabled():
- handler = TranslatorHandler(enabled=False)
+def test_get_supported_languages_no_backends():
+ handler = TranslatorHandler()
+ handler.has_requests = False
+ handler.has_argos = False
+ handler.has_argos_lib = False
+ handler.has_argos_cli = False
assert handler.get_supported_languages() == []
@@ -37,7 +45,11 @@ def test_get_supported_languages_libretranslate(mock_session_cls):
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session_cls.return_value = mock_session
- handler = TranslatorHandler(enabled=True)
+ handler = TranslatorHandler()
+ handler.has_argos = False
+ handler.has_argos_lib = False
+ handler.has_argos_cli = False
+ handler.has_requests = True
langs = handler.get_supported_languages()
assert len(langs) == 2
assert langs[0]["code"] == "en"
@@ -58,7 +70,8 @@ def test_translate_libretranslate(mock_session_cls):
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session_cls.return_value = mock_session
- handler = TranslatorHandler(enabled=True)
+ handler = TranslatorHandler(translator_libretranslate_enabled=True)
+ handler.has_requests = True
result = handler.translate_text("Hello", source_lang="en", target_lang="fr")
assert result["translated_text"] == "Bonjour"
@@ -69,7 +82,9 @@ def test_translate_argos_cli(mock_run):
mock_result.stdout = "Hola"
mock_run.return_value = mock_result
- handler = TranslatorHandler(enabled=True)
+ handler = TranslatorHandler(
+ translator_argos_enabled=True, translator_libretranslate_enabled=False
+ )
handler.has_argos_cli = True
handler.has_argos = True
handler.has_requests = False # Force CLI
@@ -86,7 +101,7 @@ def test_translate_argos_cli(mock_run):
def test_detect_language_simple():
- TranslatorHandler(enabled=True)
+ TranslatorHandler()
# _detect_language is private
@@ -109,17 +124,22 @@ def test_detect_language_libretranslate(mock_session_cls):
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session_cls.return_value = mock_session
- handler = TranslatorHandler(enabled=True)
+ handler = TranslatorHandler(translator_libretranslate_enabled=True)
+ handler.has_requests = True
result = handler.translate_text("Hello world", source_lang="auto", target_lang="fr")
assert result["source_lang"] == "en"
def test_translator_handler_errors():
- handler = TranslatorHandler(enabled=False)
+ handler = TranslatorHandler(
+ translator_argos_enabled=False,
+ translator_libretranslate_enabled=False,
+ )
with pytest.raises(RuntimeError, match="Translator is disabled"):
handler.translate_text("Hello", "en", "fr")
- handler.enabled = True
+ handler.translator_argos_enabled = True
+ handler.translator_libretranslate_enabled = True
with pytest.raises(ValueError, match="Text cannot be empty"):
handler.translate_text("", "en", "fr")
diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index 6c702b26..6b4fdc42 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -18,6 +18,7 @@ describe("AboutPage.vue", () => {
let axiosMock;
beforeEach(() => {
+ vi.clearAllMocks();
vi.useFakeTimers();
axiosMock = {
get: vi.fn().mockImplementation(() => Promise.resolve({ data: {} })),
@@ -212,7 +213,7 @@ describe("AboutPage.vue", () => {
expect(axiosMock.get).toHaveBeenCalledTimes(7); // +2 from updateInterval
});
- it("handles vacuum database action", async () => {
+ it("handles vacuum database action and shows success toast", async () => {
axiosMock.get.mockResolvedValue({
data: {
app_info: {},
@@ -232,12 +233,111 @@ describe("AboutPage.vue", () => {
const wrapper = mountAboutPage();
await wrapper.vm.$nextTick();
- // Find vacuum button (it's the second button in the database health section)
- // Or we can just call the method directly to be sure
await wrapper.vm.vacuumDatabase();
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/vacuum");
expect(wrapper.vm.databaseActionMessage).toBe("Vacuum success");
+ expect(ToastUtils.success).toHaveBeenCalledWith("about.vacuum_complete");
+ });
+
+ it("shows error toast when vacuum fails", async () => {
+ axiosMock.get.mockResolvedValue({
+ data: {
+ app_info: {},
+ config: {},
+ database: {},
+ },
+ });
+ const apiErr = new Error("vacuum failed");
+ apiErr.response = { data: { message: "Failed to vacuum database: locked" } };
+ axiosMock.post.mockRejectedValue(apiErr);
+
+ const wrapper = mountAboutPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.vacuumDatabase();
+
+ expect(ToastUtils.error).toHaveBeenCalledWith("Failed to vacuum database: locked");
+ expect(wrapper.vm.databaseActionError).toBe("about.vacuum_failed");
+ });
+
+ it("handles database recovery and shows success toast", async () => {
+ vi.spyOn(DialogUtils, "confirm").mockResolvedValue(true);
+ axiosMock.get.mockResolvedValue({
+ data: {
+ app_info: {},
+ config: {},
+ database: {
+ quick_check: "ok",
+ journal_mode: "wal",
+ page_count: 1,
+ estimated_free_bytes: 0,
+ },
+ },
+ });
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/database/recover") {
+ return Promise.resolve({
+ data: {
+ message: "Database recovery routine completed",
+ database: {
+ health: {
+ quick_check: "ok",
+ journal_mode: "wal",
+ page_count: 2,
+ estimated_free_bytes: 100,
+ },
+ actions: [{ step: "wal_checkpoint", result: [] }],
+ },
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountAboutPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.runRecovery();
+
+ expect(DialogUtils.confirm).toHaveBeenCalledWith("about.recovery_confirm");
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/recover");
+ expect(ToastUtils.success).toHaveBeenCalledWith("about.recovery_complete");
+ expect(wrapper.vm.databaseRecoveryActions.length).toBe(1);
+ });
+
+ it("does not run recovery when user cancels the confirm dialog", async () => {
+ vi.spyOn(DialogUtils, "confirm").mockResolvedValue(false);
+ axiosMock.get.mockResolvedValue({
+ data: { app_info: {}, config: {}, database: {} },
+ });
+
+ const wrapper = mountAboutPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.runRecovery();
+
+ expect(DialogUtils.confirm).toHaveBeenCalledWith("about.recovery_confirm");
+ expect(axiosMock.post).not.toHaveBeenCalledWith("/api/v1/database/recover");
+ expect(ToastUtils.success).not.toHaveBeenCalledWith("about.recovery_complete");
+ });
+
+ it("shows error toast when recovery fails", async () => {
+ vi.spyOn(DialogUtils, "confirm").mockResolvedValue(true);
+ axiosMock.get.mockResolvedValue({
+ data: { app_info: {}, config: {}, database: {} },
+ });
+ const apiErr = new Error("recover failed");
+ apiErr.response = { data: { message: "Failed to recover database: corrupt" } };
+ axiosMock.post.mockRejectedValue(apiErr);
+
+ const wrapper = mountAboutPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.runRecovery();
+
+ expect(ToastUtils.error).toHaveBeenCalledWith("Failed to recover database: corrupt");
+ expect(wrapper.vm.databaseActionError).toBe("about.recovery_failed");
});
it("displays Free Space from database health", async () => {
diff --git a/tests/frontend/AppSidebarIdentityAnnounce.test.js b/tests/frontend/AppSidebarIdentityAnnounce.test.js
new file mode 100644
index 00000000..3e5635e1
--- /dev/null
+++ b/tests/frontend/AppSidebarIdentityAnnounce.test.js
@@ -0,0 +1,235 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createRouter, createWebHashHistory } from "vue-router";
+import { createI18n } from "vue-i18n";
+import { createVuetify } from "vuetify";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+import { appPackageVersion } from "./fixtures/repoPackageVersion.js";
+import en from "../../meshchatx/src/frontend/locales/en.json";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ connect: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ send: vi.fn(),
+ destroy: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+const axiosMock = { get: vi.fn() };
+const vuetify = createVuetify();
+const i18n = createI18n({
+ legacy: false,
+ locale: "en",
+ messages: { en },
+});
+
+const routes = [
+ { path: "/", name: "messages", component: { template: "<div>Messages</div>" } },
+ { path: "/nomadnetwork", name: "nomadnetwork", component: { template: "<div>Nomad</div>" } },
+ { path: "/contacts", name: "contacts", component: { template: "<div>Contacts</div>" } },
+ { path: "/map", name: "map", component: { template: "<div>Map</div>" } },
+ { path: "/archives", name: "archives", component: { template: "<div>Archives</div>" } },
+ { path: "/call", name: "call", component: { template: "<div>Call</div>" } },
+ { path: "/interfaces", name: "interfaces", component: { template: "<div>Interfaces</div>" } },
+ { path: "/network-visualiser", name: "network-visualiser", component: { template: "<div>Network</div>" } },
+ { path: "/tools", name: "tools", component: { template: "<div>Tools</div>" } },
+ { path: "/settings", name: "settings", component: { template: "<div>Settings</div>" } },
+ { path: "/identities", name: "identities", component: { template: "<div>Identities</div>" } },
+ { path: "/about", name: "about", component: { template: "<div>About</div>" } },
+ { path: "/profile/icon", name: "profile.icon", component: { template: "<div>Profile</div>" } },
+ { path: "/changelog", name: "changelog", component: { template: "<div>Changelog</div>" } },
+ { path: "/tutorial", name: "tutorial", component: { template: "<div>Tutorial</div>" } },
+];
+
+const appStubs = {
+ MaterialDesignIcon: { template: '<span class="md-stub" />' },
+ LxmfUserIcon: { template: "<div />" },
+ NotificationBell: true,
+ LanguageSelector: true,
+ CallOverlay: true,
+ CommandPalette: true,
+ IntegrityWarningModal: true,
+ AppShellBanners: true,
+ Toast: true,
+ VDialog: true,
+ VCard: true,
+ VCardText: true,
+ VCardActions: true,
+ VBtn: true,
+ VIcon: true,
+ VToolbar: true,
+ VToolbarTitle: true,
+ VSpacer: true,
+ VProgressCircular: true,
+ VCheckbox: true,
+ VDivider: true,
+};
+
+function makeConfig(overrides = {}) {
+ return {
+ theme: "dark",
+ display_name: "Test User",
+ auto_announce_interval_seconds: 0,
+ last_announced_at: null,
+ identity_hash: "h1",
+ lxmf_address_hash: "lx1",
+ identity_public_key: "pk1",
+ lxmf_user_icon_name: "face-man",
+ lxmf_user_icon_foreground_colour: "#e4e4e7",
+ lxmf_user_icon_background_colour: "#3f3f46",
+ language: "en",
+ ...overrides,
+ };
+}
+
+function defaultAxiosImplementation(url) {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: appPackageVersion,
+ tutorial_seen: true,
+ changelog_seen_version: appPackageVersion,
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: makeConfig() } });
+ }
+ if (url === "/api/v1/announce") {
+ return Promise.resolve({ data: {} });
+ }
+ if (url === "/api/v1/auth/status") {
+ return Promise.resolve({ data: { auth_enabled: false } });
+ }
+ if (url === "/api/v1/blocked-destinations") {
+ return Promise.resolve({ data: { blocked_destinations: [] } });
+ }
+ if (url === "/api/v1/telephone/status") {
+ return Promise.resolve({ data: { active_call: null } });
+ }
+ if (url === "/api/v1/lxmf/propagation-node/status") {
+ return Promise.resolve({ data: { propagation_node_status: { state: "idle" } } });
+ }
+ return Promise.resolve({ data: {} });
+}
+
+function makeMountedApp() {
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes,
+ });
+ return mount(App, {
+ global: {
+ plugins: [router, vuetify, i18n],
+ stubs: appStubs,
+ },
+ });
+}
+
+describe("App.vue sidebar identity label and announce control", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ window.api = axiosMock;
+ vi.clearAllMocks();
+ axiosMock.get.mockImplementation(defaultAxiosImplementation);
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount();
+ wrapper = undefined;
+ }
+ delete window.api;
+ });
+
+ async function readyShell(r) {
+ await r.isReady();
+ await flushPromises();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ it("shows configured display name instead of My Identity", async () => {
+ wrapper = makeMountedApp();
+ const r = wrapper.vm.$router;
+ await readyShell(r);
+ const html = wrapper.html();
+ expect(html).toContain("Test User");
+ expect(html).not.toMatch(/>My Identity</);
+ });
+
+ it("falls back to My Identity when display name is empty", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: makeConfig({ display_name: "" }) } });
+ }
+ return defaultAxiosImplementation(url);
+ });
+ wrapper = makeMountedApp();
+ await readyShell(wrapper.vm.$router);
+ expect(wrapper.html()).toContain("My Identity");
+ });
+
+ it("long display name is exposed in title and uses truncate for layout", async () => {
+ const long = "A".repeat(200);
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: makeConfig({ display_name: long }) } });
+ }
+ return defaultAxiosImplementation(url);
+ });
+ wrapper = makeMountedApp();
+ await readyShell(wrapper.vm.$router);
+ expect(wrapper.vm.identitySidebarLabel).toBe(long);
+ const titled = wrapper.find(`div[title="${long}"]`);
+ expect(titled.exists()).toBe(true);
+ expect(titled.attributes("class") ?? "").toMatch(/truncate/);
+ });
+
+ it("sidebar radio sends announce and still works when sidebar is collapsed", async () => {
+ wrapper = makeMountedApp();
+ await readyShell(wrapper.vm.$router);
+ const btn = wrapper.find("[data-testid=sidebar-announce-radio]");
+ expect(btn.exists()).toBe(true);
+ wrapper.vm.isShowingAnnounceSection = true;
+ await btn.trigger("click");
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce");
+ expect(ToastUtils.success).toHaveBeenCalled();
+ expect(wrapper.vm.isShowingAnnounceSection).toBe(true);
+ vi.clearAllMocks();
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/announce") {
+ return Promise.resolve({ data: {} });
+ }
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: makeConfig() } });
+ }
+ return defaultAxiosImplementation(url);
+ });
+ wrapper.vm.isSidebarCollapsed = true;
+ await btn.trigger("click");
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce");
+ });
+
+ it("clicking announce section header (not the radio) toggles expanded state", async () => {
+ wrapper = makeMountedApp();
+ await readyShell(wrapper.vm.$router);
+ const header = wrapper.find("[data-testid=sidebar-announce-header]");
+ expect(header.exists()).toBe(true);
+ wrapper.vm.isShowingAnnounceSection = true;
+ await header.trigger("click");
+ expect(wrapper.vm.isShowingAnnounceSection).toBe(false);
+ });
+});
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index ca9456a6..b507280c 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -4,6 +4,8 @@ import ConversationViewer from "@/components/messages/ConversationViewer.vue";
import WebSocketConnection from "@/js/WebSocketConnection";
import GlobalState from "@/js/GlobalState";
import DialogUtils from "@/js/DialogUtils";
+import ToastUtils from "@/js/ToastUtils";
+import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../meshchatx/src/frontend/js/messageDisplayLimits.js";
vi.mock("@/js/DialogUtils", () => ({
default: {
@@ -486,13 +488,107 @@ describe("ConversationViewer.vue", () => {
...navigator,
clipboard: { readText },
});
+ const prevSc = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ try {
+ const wrapper = mountConversationViewer();
+ const ta = wrapper.find("#message-input").element;
+ ta.selectionStart = 0;
+ ta.selectionEnd = 0;
+ wrapper.vm.newMessageText = "";
+ await wrapper.vm.pasteFromClipboard();
+ expect(wrapper.vm.newMessageText).toBe("pasted-text");
+ } finally {
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc });
+ }
+ });
+
+ it("pasteFromClipboard toasts insecure context and does not call readText", async () => {
+ const readText = vi.fn(() => Promise.resolve("never"));
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { readText },
+ });
+ const prevSc = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: false });
+ const errSpy = vi.spyOn(ToastUtils, "error").mockImplementation(() => {});
+ try {
+ const wrapper = mountConversationViewer();
+ await wrapper.vm.pasteFromClipboard();
+ expect(readText).not.toHaveBeenCalled();
+ expect(errSpy).toHaveBeenCalledWith("messages.clipboard_read_requires_secure_context");
+ } finally {
+ errSpy.mockRestore();
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc });
+ }
+ });
+
+ it("showRawMessage loads raw uri and keeps stored path fields from the message", async () => {
+ const peer = "a".repeat(32);
+ const msgHash = "b".repeat(32);
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/lxmf-messages/") && url.includes("/uri")) {
+ return Promise.resolve({ data: { uri: "lxmf://packed-uri" } });
+ }
+ if (url.includes("/destination/") && url.includes("/path")) {
+ return Promise.resolve({
+ data: { path: { hops: 99, next_hop_interface: "Should not use" } },
+ });
+ }
+ if (url.includes("/path")) return Promise.resolve({ data: { path: [] } });
+ if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } });
+ if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } });
+ return Promise.resolve({ data: {} });
+ });
+ const wrapper = mountConversationViewer({
+ selectedPeer: { destination_hash: peer, display_name: "Peer" },
+ myLxmfAddressHash: "c".repeat(32),
+ });
+ const getCallsBefore = axiosMock.get.mock.calls.length;
+ await wrapper.vm.showRawMessage({
+ lxmf_message: {
+ hash: msgHash,
+ source_hash: "d".repeat(32),
+ destination_hash: peer,
+ state: "delivered",
+ method: "direct",
+ content: "hi",
+ fields: {},
+ id: 42,
+ path_hops_at_send: 3,
+ path_interface_at_send: "Default Interface",
+ },
+ });
+ expect(wrapper.vm.isRawMessageModalOpen).toBe(true);
+ expect(wrapper.vm.rawMessageData.raw_uri).toBe("lxmf://packed-uri");
+ expect(wrapper.vm.rawMessageData.path_hops_at_send).toBe(3);
+ expect(wrapper.vm.rawMessageData.path_interface_at_send).toBe("Default Interface");
+ const callsDuringRaw = axiosMock.get.mock.calls.slice(getCallsBefore);
+ const destinationPathCalls = callsDuringRaw.filter(
+ (c) => typeof c[0] === "string" && c[0].includes("/destination/") && c[0].includes("/path")
+ );
+ expect(destinationPathCalls.length).toBe(0);
+ expect(callsDuringRaw.some((c) => c[0].includes("/uri"))).toBe(true);
+ });
+
+ it("isMessageBodyTooLargeForDisplay is true only above display limit", () => {
const wrapper = mountConversationViewer();
- const ta = wrapper.find("#message-input").element;
- ta.selectionStart = 0;
- ta.selectionEnd = 0;
- wrapper.vm.newMessageText = "";
- await wrapper.vm.pasteFromClipboard();
- expect(wrapper.vm.newMessageText).toBe("pasted-text");
+ const atLimit = { lxmf_message: { content: "x".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS) } };
+ const over = { lxmf_message: { content: "x".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 1) } };
+ expect(wrapper.vm.isMessageBodyTooLargeForDisplay(atLimit)).toBe(false);
+ expect(wrapper.vm.isMessageBodyTooLargeForDisplay(over)).toBe(true);
+ });
+
+ it("rawMessageJsonPreviewPretty replaces huge content for JSON details", async () => {
+ const wrapper = mountConversationViewer();
+ const huge = "z".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 5000);
+ await wrapper.setData({
+ rawMessageData: { id: 1, content: huge, hash: "b".repeat(32) },
+ });
+ const s = wrapper.vm.rawMessageJsonPreviewPretty;
+ expect(s).not.toContain(huge);
+ expect(s).toContain("Omitted");
+ expect(s).toContain(String(huge.length));
});
it("adds multiple images and renders previews", async () => {
@@ -822,7 +918,7 @@ describe("ConversationViewer.vue", () => {
"background-color": "#ff0000",
color: "#ffffff",
});
- expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("shadow-sm");
+ expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("shadow-xs");
expect(wrapper.vm.isThemeOutboundBubble(chatItem)).toBe(false);
});
@@ -1053,6 +1149,68 @@ describe("ConversationViewer.vue", () => {
expect(conversationGets().length).toBe(countBefore);
});
+
+ it("uses min loaded peer message id for pagination when telemetry-only rows are hidden from the list", async () => {
+ const deferredResolvers = deferredConversationGet();
+ const peerHash = "a".repeat(32);
+ const wrapper = mountConversationViewer({
+ selectedPeer: { destination_hash: peerHash, display_name: "A" },
+ });
+ await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1));
+ deferredResolvers[0]({ data: { lxmf_messages: [] } });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.showTelemetryInChat).toBe(false);
+
+ wrapper.vm.chatItems = [
+ {
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ id: 100,
+ hash: "h100",
+ source_hash: peerHash,
+ destination_hash: "my-hash",
+ content: "",
+ state: "delivered",
+ timestamp: 1700000000,
+ fields: { commands: [{ "0x01": [1, true] }] },
+ },
+ },
+ {
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ id: 200,
+ hash: "h200",
+ source_hash: peerHash,
+ destination_hash: "my-hash",
+ content: "hello",
+ state: "delivered",
+ timestamp: 1700000001,
+ fields: {},
+ },
+ },
+ ];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
+ expect(wrapper.vm.oldestMessageId).toBe(100);
+
+ axiosMock.get.mockImplementation((url, config) => {
+ if (url.includes("/lxmf-messages/conversation/")) {
+ expect(config.params.after_id).toBe(100);
+ return Promise.resolve({ data: { lxmf_messages: [] } });
+ }
+ if (url.includes("/path")) return Promise.resolve({ data: { path: [] } });
+ if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } });
+ if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } });
+ if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} });
+ return Promise.resolve({ data: {} });
+ });
+
+ await wrapper.vm.loadPrevious();
+ });
});
describe("compose draft persistence", () => {
diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js
index 5cad0593..fa16c808 100644
--- a/tests/frontend/ConversationViewerButtons.test.js
+++ b/tests/frontend/ConversationViewerButtons.test.js
@@ -215,6 +215,69 @@ describe("ConversationViewer.vue button interactions", () => {
expect(deleteSpy).toHaveBeenCalledWith(chatItem);
});
+ it("default translate target prefers meshchatx.translateTargetLang from localStorage", async () => {
+ localStorage.getItem.mockImplementation((k) => {
+ if (k === "meshchatx.translateTargetLang") {
+ return "de";
+ }
+ return null;
+ });
+ const wrapper = mountViewer({
+ config: {
+ translator_argos_enabled: true,
+ translator_libretranslate_enabled: false,
+ language: "en",
+ },
+ });
+ await wrapper.vm.$nextTick();
+ wrapper.vm.translatorLanguages = [
+ { code: "en", name: "English" },
+ { code: "de", name: "German" },
+ ];
+ expect(wrapper.vm.defaultTranslateTargetForModal()).toBe("de");
+ expect(wrapper.vm.defaultBubbleTranslateTargetForModal()).toBe("de");
+ });
+
+ it("openBubbleTranslateFromContextMenu opens the bubble target bar on the next microtask", async () => {
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ hash: "msg-tr",
+ content: "Hello for translate",
+ state: "delivered",
+ fields: {},
+ },
+ };
+ const wrapper = mountViewer({
+ config: {
+ translator_argos_enabled: true,
+ translator_libretranslate_enabled: false,
+ language: "en",
+ },
+ });
+ await wrapper.vm.$nextTick();
+ wrapper.vm.hasTranslator = true;
+ wrapper.vm.translatorLanguages = [
+ { code: "en", name: "English" },
+ { code: "de", name: "German" },
+ ];
+ wrapper.vm.messageContextMenu = {
+ show: true,
+ x: 0,
+ y: 0,
+ chatItem,
+ justOpened: false,
+ openedFromBubble: true,
+ };
+ wrapper.vm.openBubbleTranslateFromContextMenu();
+ expect(wrapper.vm.translateTargetBarOpen).toBe(false);
+ expect(wrapper.vm.messageContextMenu.show).toBe(false);
+ await new Promise((r) => queueMicrotask(r));
+ await vi.waitFor(() => expect(wrapper.vm.translateTargetBarOpen).toBe(true), { timeout: 2000 });
+ expect(wrapper.vm.translateTargetModalContext).toEqual({ type: "bubble", chatItem });
+ });
+
it("call button exists and onStartCall is callable", async () => {
const wrapper = mountViewer();
expect(typeof wrapper.vm.onStartCall).toBe("function");
@@ -263,34 +326,48 @@ describe("ConversationViewer.vue button interactions", () => {
...navigator,
clipboard: { readText },
});
- const wrapper = mountViewer();
- await wrapper.vm.$nextTick();
- const ta = wrapper.find("#message-input").element;
- ta.selectionStart = 0;
- ta.selectionEnd = 0;
- wrapper.vm.newMessageText = "";
-
- const actionButtons = wrapper.findAll(".attachment-action-button");
- await actionButtons[1].trigger("click");
- await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard"));
+ const prevSc = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ try {
+ const wrapper = mountViewer();
+ await wrapper.vm.$nextTick();
+ const ta = wrapper.find("#message-input").element;
+ ta.selectionStart = 0;
+ ta.selectionEnd = 0;
+ wrapper.vm.newMessageText = "";
+
+ const actionButtons = wrapper.findAll(".attachment-action-button");
+ await actionButtons[1].trigger("click");
+ await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard"));
+ } finally {
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc });
+ }
});
- it("translateMessage replaces text when the translator API succeeds", async () => {
+ it("applyComposeTranslation posts translate and replaces text", async () => {
axiosMock.post.mockImplementation((url) => {
if (url.includes("/translator/translate")) {
return Promise.resolve({ data: { translated_text: "translated" } });
}
return Promise.resolve({ data: {} });
});
- const wrapper = mountViewer();
+ const wrapper = mountViewer({
+ config: {
+ translator_argos_enabled: true,
+ translator_libretranslate_enabled: false,
+ language: "en",
+ },
+ });
wrapper.vm.newMessageText = "hello";
- await wrapper.vm.translateMessage();
+ await wrapper.vm.applyComposeTranslation("de");
expect(wrapper.vm.newMessageText).toBe("translated");
expect(axiosMock.post).toHaveBeenCalledWith(
"/api/v1/translator/translate",
expect.objectContaining({
text: "hello",
- target_lang: "en",
+ source_lang: "en",
+ target_lang: "de",
+ use_argos: true,
})
);
});
diff --git a/tests/frontend/Interface.test.js b/tests/frontend/Interface.test.js
index 516d98db..6e464efb 100644
--- a/tests/frontend/Interface.test.js
+++ b/tests/frontend/Interface.test.js
@@ -57,7 +57,7 @@ describe("Interface.vue", () => {
expect(contentArea.exists()).toBe(true);
});
- it("has break-words on description for long host:port", () => {
+ it("has word-wrap on description for long host:port", () => {
const wrapper = mountInterface({
_name: "RNS Testnet Amsterdam",
type: "TCPClientInterface",
@@ -65,7 +65,7 @@ describe("Interface.vue", () => {
target_port: 4965,
});
const desc = wrapper.find(".text-sm.text-gray-600");
- expect(desc.classes()).toContain("break-words");
+ expect(desc.classes()).toContain("wrap-break-word");
expect(desc.classes()).toContain("min-w-0");
});
diff --git a/tests/frontend/RNodeFlasherPage.test.js b/tests/frontend/RNodeFlasherPage.test.js
index 4fcc9d11..3260e4bd 100644
--- a/tests/frontend/RNodeFlasherPage.test.js
+++ b/tests/frontend/RNodeFlasherPage.test.js
@@ -1,10 +1,31 @@
-import { mount } from "@vue/test-utils";
+import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const { toastError, toastSuccess, toastInfo, toastWarning } = vi.hoisted(() => ({
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastInfo: vi.fn(),
+ toastWarning: vi.fn(),
+}));
+
+vi.mock("@/js/ToastUtils.js", () => ({
+ default: {
+ error: toastError,
+ success: toastSuccess,
+ info: toastInfo,
+ warning: toastWarning,
+ },
+}));
+
import RNodeFlasherPage from "@/components/tools/RNodeFlasherPage.vue";
describe("RNodeFlasherPage.vue", () => {
beforeEach(() => {
- // Mock global fetch for the latest_release proxy
+ toastError.mockClear();
+ toastSuccess.mockClear();
+ toastInfo.mockClear();
+ toastWarning.mockClear();
+
window.fetch = vi.fn().mockImplementation((url) => {
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
return Promise.resolve({
@@ -12,7 +33,13 @@ describe("RNodeFlasherPage.vue", () => {
json: () =>
Promise.resolve({
tag_name: "v1.0",
- assets: [{ name: "firmware.zip", browser_download_url: "http://example.com/firmware.zip" }],
+ assets: [
+ {
+ name: "firmware.zip",
+ browser_download_url:
+ "https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip",
+ },
+ ],
}),
});
}
@@ -50,6 +77,20 @@ describe("RNodeFlasherPage.vue", () => {
expect(wrapper.text()).toContain("1. tools.rnode_flasher.select_device");
});
+ it("requests latest_release without a repo query (GitHub default on server)", async () => {
+ mountRNodeFlasherPage();
+ await vi.waitFor(() => {
+ expect(window.fetch).toHaveBeenCalled();
+ });
+ const releaseCalls = window.fetch.mock.calls.filter(
+ (c) => typeof c[0] === "string" && c[0].includes("latest_release")
+ );
+ expect(releaseCalls.length).toBeGreaterThanOrEqual(1);
+ const u = releaseCalls[0][0];
+ expect(u).toBe("/api/v1/tools/rnode/latest_release");
+ expect(u).not.toContain("?");
+ });
+
it("toggles advanced mode", async () => {
const wrapper = mountRNodeFlasherPage();
expect(wrapper.vm.showAdvanced).toBe(false);
@@ -87,11 +128,137 @@ describe("RNodeFlasherPage.vue", () => {
expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe("https://gitea/example.zip");
});
- it("falls back to the gitea releases/latest/download URL when the release lookup failed", () => {
+ it("falls back to the GitHub releases/latest/download URL when the release lookup failed", () => {
const wrapper = mountRNodeFlasherPage();
wrapper.vm.selectedProduct = { firmware_filename: "rnode_firmware_heltec32v3.zip" };
wrapper.vm.latestRelease = null;
const url = wrapper.vm._resolveRecommendedAssetUrl();
- expect(url).toMatch(/\/Reticulum\/RNode_Firmware\/releases\/latest\/download\/rnode_firmware_heltec32v3\.zip$/);
+ expect(url).toBe(
+ "https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_heltec32v3.zip"
+ );
+ });
+
+ it("uses model firmware_filename when present for fallback URL", () => {
+ const wrapper = mountRNodeFlasherPage();
+ wrapper.vm.selectedProduct = { models: [] };
+ wrapper.vm.selectedModel = { firmware_filename: "rnode_firmware_tbeam.zip" };
+ wrapper.vm.latestRelease = null;
+ expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe(
+ "https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_tbeam.zip"
+ );
+ });
+
+ it("links footer firmware and flasher pages to GitHub", () => {
+ const wrapper = mountRNodeFlasherPage();
+ const html = wrapper.html();
+ expect(html).toContain('href="https://github.com/markqvist/RNode_Firmware"');
+ expect(html).toContain('href="https://github.com/liamcottle/rnode-flasher"');
+ });
+
+ it("downloadRecommendedFirmware requests proxied download with encoded GitHub URL", async () => {
+ const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00]);
+ const assetUrl = "https://github.com/markqvist/RNode_Firmware/releases/download/v1.0/firmware.zip";
+
+ window.fetch = vi.fn().mockImplementation((url) => {
+ if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tag_name: "v1.0",
+ assets: [{ name: "firmware.zip", browser_download_url: assetUrl }],
+ }),
+ });
+ }
+ if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) {
+ expect(url).toContain(encodeURIComponent(assetUrl));
+ return Promise.resolve({
+ ok: true,
+ blob: () => Promise.resolve(new Blob([zipBytes], { type: "application/zip" })),
+ });
+ }
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
+ });
+
+ const wrapper = mount(RNodeFlasherPage, {
+ global: {
+ mocks: {
+ $t: (key, params) => key + (params ? JSON.stringify(params) : ""),
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
+ props: ["iconName"],
+ },
+ "v-icon": true,
+ "v-progress-circular": true,
+ "v-progress-linear": true,
+ RNodeFirmwareSelector: {
+ name: "RNodeFirmwareSelectorStub",
+ template: "<div />",
+ methods: { setFile: vi.fn() },
+ },
+ },
+ },
+ });
+ await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull());
+ wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
+
+ await wrapper.vm.downloadRecommendedFirmware();
+ await flushPromises();
+
+ expect(toastSuccess).toHaveBeenCalledWith("tools.rnode_flasher.alerts.firmware_downloaded");
+ expect(wrapper.vm.firmwareFile).not.toBe(null);
+ expect(wrapper.vm.firmwareFile.name).toBe("firmware.zip");
+ });
+
+ it("downloadRecommendedFirmware shows error when no firmware filename", async () => {
+ const wrapper = mountRNodeFlasherPage();
+ wrapper.vm.selectedProduct = null;
+ wrapper.vm.selectedModel = null;
+ await wrapper.vm.downloadRecommendedFirmware();
+ expect(toastError).toHaveBeenCalledWith("tools.rnode_flasher.errors.firmware_not_found_in_release");
+ });
+
+ it("downloadRecommendedFirmware shows error when download fails", async () => {
+ window.fetch = vi.fn().mockImplementation((url) => {
+ if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tag_name: "v1.0",
+ assets: [
+ {
+ name: "firmware.zip",
+ browser_download_url:
+ "https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip",
+ },
+ ],
+ }),
+ });
+ }
+ if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) {
+ return Promise.resolve({
+ ok: false,
+ status: 502,
+ statusText: "Bad Gateway",
+ json: () => Promise.resolve({ error: "upstream broke" }),
+ });
+ }
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
+ });
+
+ const wrapper = mountRNodeFlasherPage();
+ await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull());
+ wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
+
+ await wrapper.vm.downloadRecommendedFirmware();
+
+ expect(toastError).toHaveBeenCalled();
+ const msg = toastError.mock.calls[0][0];
+ expect(msg).toContain("tools.rnode_flasher.errors.failed_download");
+ expect(msg).toContain("upstream broke");
});
});
diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index 03bb28af..ee29425b 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -91,6 +91,14 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { theme: "light" });
});
+ it("onAnnounceStoreToggle PATCHes a single announce_store flag", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.announce_store_lxmf_delivery = true;
+ await w.vm.onAnnounceStoreToggle("announce_store_lxmf_delivery", false);
+ expect(w.vm.config.announce_store_lxmf_delivery).toBe(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { announce_store_lxmf_delivery: false });
+ });
+
it("onLanguageChange PATCHes language", async () => {
const w = await mountSettingsPage(api);
w.vm.config.language = "de";
@@ -424,18 +432,6 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect(router.push).toHaveBeenCalledWith({ name: "auth" });
});
- it("translator toggle and debounced URL PATCH", async () => {
- const w = await mountSettingsPage(api);
- await w.vm.onTranslatorEnabledChange(true);
- expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { translator_enabled: true });
- w.vm.config.libretranslate_url = "http://translate.example";
- await w.vm.onTranslatorConfigChange();
- await vi.advanceTimersByTimeAsync(1000);
- expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
- libretranslate_url: "http://translate.example",
- });
- });
-
it("onGiteaConfigChange PATCHes after debounce", async () => {
const w = await mountSettingsPage(api);
w.vm.config.gitea_base_url = "https://gitea.example";
diff --git a/tests/frontend/TranslatorPage.test.js b/tests/frontend/TranslatorPage.test.js
index 4d2ce017..1195e844 100644
--- a/tests/frontend/TranslatorPage.test.js
+++ b/tests/frontend/TranslatorPage.test.js
@@ -14,7 +14,15 @@ describe("TranslatorPage.vue", () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config") {
- return Promise.resolve({ data: { config: { translator_enabled: true } } });
+ return Promise.resolve({
+ data: {
+ config: {
+ translator_argos_enabled: true,
+ translator_libretranslate_enabled: true,
+ libretranslate_url: "http://localhost:5000",
+ },
+ },
+ });
}
if (url === "/api/v1/translator/languages") {
return Promise.resolve({
@@ -26,6 +34,8 @@ describe("TranslatorPage.vue", () => {
{ code: "de", name: "German", source: "libretranslate" },
],
has_argos: true,
+ libre_client_available: true,
+ libretranslate_reachable: true,
},
});
}
@@ -61,6 +71,40 @@ describe("TranslatorPage.vue", () => {
expect(wrapper.text()).toContain("Translator");
});
+ it("shows Libre tab when HTTP client is available even if server is not reachable yet", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({
+ data: {
+ config: {
+ translator_argos_enabled: false,
+ translator_libretranslate_enabled: false,
+ libretranslate_url: "http://127.0.0.1:5000",
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/translator/languages") {
+ return Promise.resolve({
+ data: {
+ languages: [],
+ has_argos: false,
+ libre_client_available: true,
+ libretranslate_reachable: false,
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ const wrapper = mountTranslatorPage();
+ await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull());
+ const libreTab = wrapper.findAll("button").find((b) => b.text().includes("LibreTranslate"));
+ expect(libreTab).toBeDefined();
+ await libreTab.trigger("click");
+ expect(wrapper.vm.translationMode).toBe("libretranslate");
+ expect(wrapper.text()).toContain("LibreTranslate API Server");
+ });
+
it("switches translation modes", async () => {
const wrapper = mountTranslatorPage();
await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull());
diff --git a/tests/frontend/clipboardUtils.test.js b/tests/frontend/clipboardUtils.test.js
new file mode 100644
index 00000000..9e392462
--- /dev/null
+++ b/tests/frontend/clipboardUtils.test.js
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ copyTextToClipboard,
+ readTextFromClipboard,
+ isWindowSecureContext,
+} from "../../meshchatx/src/frontend/js/clipboardUtils.js";
+
+describe("clipboardUtils", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("copyTextToClipboard uses execCommand when writeText rejects", async () => {
+ const writeText = vi.fn(() => Promise.reject(new Error("blocked")));
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { writeText },
+ });
+ const prevExec = document.execCommand;
+ const execCommand = vi.fn(() => true);
+ document.execCommand = execCommand;
+ try {
+ const ok = await copyTextToClipboard("hello");
+ expect(ok).toBe(true);
+ expect(writeText).toHaveBeenCalledWith("hello");
+ expect(execCommand).toHaveBeenCalledWith("copy");
+ } finally {
+ document.execCommand = prevExec;
+ }
+ });
+
+ it("readTextFromClipboard returns insecure_context when window.isSecureContext is false (e.g. http://0.0.0.0)", async () => {
+ const readText = vi.fn(() => Promise.resolve("should-not-run"));
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { readText },
+ });
+ const prev = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", {
+ configurable: true,
+ value: false,
+ });
+ const result = await readTextFromClipboard();
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe("insecure_context");
+ expect(readText).not.toHaveBeenCalled();
+ Object.defineProperty(window, "isSecureContext", {
+ configurable: true,
+ value: prev,
+ });
+ });
+
+ it("readTextFromClipboard reads when secure and API present", async () => {
+ const readText = vi.fn(() => Promise.resolve("body"));
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { readText },
+ });
+ const prev = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ try {
+ const result = await readTextFromClipboard();
+ expect(result.ok).toBe(true);
+ expect(result.text).toBe("body");
+ expect(readText).toHaveBeenCalled();
+ } finally {
+ Object.defineProperty(window, "isSecureContext", {
+ configurable: true,
+ value: prev,
+ });
+ }
+ });
+
+ it("isWindowSecureContext is false when explicitly false", () => {
+ const prev = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: false });
+ expect(isWindowSecureContext()).toBe(false);
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: prev });
+ });
+});
diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js
index 308f29a8..c2bb2217 100644
--- a/tests/frontend/fixtures/settingsPageTestApi.js
+++ b/tests/frontend/fixtures/settingsPageTestApi.js
@@ -88,11 +88,17 @@ export function buildFullServerConfig(overrides = {}) {
message_inbound_bubble_color: null,
message_failed_bubble_color: "#ef4444",
message_waiting_bubble_color: "#e5e7eb",
- translator_enabled: false,
+ translator_argos_enabled: false,
+ translator_libretranslate_enabled: false,
libretranslate_url: "http://localhost:5000",
desktop_open_calls_in_separate_window: false,
desktop_hardware_acceleration_enabled: true,
blackhole_integration_enabled: true,
+ announce_store_lxmf_delivery: true,
+ announce_store_lxst_telephony: true,
+ announce_store_nomadnetwork_node: true,
+ announce_store_lxmf_propagation: true,
+ announce_store_git_repositories: true,
announce_max_stored_lxmf_delivery: 1000,
announce_max_stored_nomadnetwork_node: 1000,
announce_max_stored_lxmf_propagation: 1000,
diff --git a/tests/frontend/localMessageRetention.test.js b/tests/frontend/localMessageRetention.test.js
new file mode 100644
index 00000000..4812418d
--- /dev/null
+++ b/tests/frontend/localMessageRetention.test.js
@@ -0,0 +1,21 @@
+import { describe, it, expect } from "vitest";
+import {
+ normalizeRetentionValue,
+ MAX_RETENTION_DAYS,
+ MAX_RETENTION_MONTHS,
+} from "../../meshchatx/src/frontend/js/localMessageRetention.js";
+
+describe("localMessageRetention", () => {
+ it("clamps day values", () => {
+ expect(normalizeRetentionValue(5, "days")).toEqual({ value: 5, unit: "days" });
+ expect(normalizeRetentionValue(99999, "days").value).toBe(MAX_RETENTION_DAYS);
+ expect(normalizeRetentionValue(0, "days").value).toBe(1);
+ });
+ it("clamps month values", () => {
+ expect(normalizeRetentionValue(2, "months")).toEqual({ value: 2, unit: "months" });
+ expect(normalizeRetentionValue(500, "months").value).toBe(MAX_RETENTION_MONTHS);
+ });
+ it("defaults bad numbers to 1", () => {
+ expect(normalizeRetentionValue("x", "days")).toEqual({ value: 1, unit: "days" });
+ });
+});
diff --git a/tests/frontend/lxmfReactions.test.js b/tests/frontend/lxmfReactions.test.js
index 878ee747..7cfdc0aa 100644
--- a/tests/frontend/lxmfReactions.test.js
+++ b/tests/frontend/lxmfReactions.test.js
@@ -50,6 +50,11 @@ describe("lxmfConversationListPreview", () => {
});
describe("mergeLxmfReactionRowsIntoMessages", () => {
+ it("returns an empty array when input is not an array", () => {
+ expect(mergeLxmfReactionRowsIntoMessages(undefined)).toEqual([]);
+ expect(mergeLxmfReactionRowsIntoMessages(null)).toEqual([]);
+ });
+
it("merges reaction rows onto parents and drops reaction-only rows", () => {
const parentHash = "a".repeat(32);
const incoming = [
@@ -77,6 +82,22 @@ describe("mergeLxmfReactionRowsIntoMessages", () => {
expect(out[0].reactions[0].sender).toBe("e".repeat(32));
});
+ it("matches reaction_to to parent hash case-insensitively", () => {
+ const parentHash = "Aa".repeat(16);
+ const incoming = [
+ { hash: parentHash, content: "hi", is_reaction: false },
+ {
+ hash: "c".repeat(32),
+ is_reaction: true,
+ reaction_to: parentHash.toLowerCase(),
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "e".repeat(32),
+ },
+ ];
+ const out = mergeLxmfReactionRowsIntoMessages(incoming);
+ expect(out[0].reactions).toHaveLength(1);
+ });
+
it("dedupes same sender and emoji", () => {
const parentHash = "a".repeat(32);
const sender = "e".repeat(32);
diff --git a/tests/frontend/messageDisplayLimits.test.js b/tests/frontend/messageDisplayLimits.test.js
new file mode 100644
index 00000000..1124f016
--- /dev/null
+++ b/tests/frontend/messageDisplayLimits.test.js
@@ -0,0 +1,23 @@
+import { describe, it, expect } from "vitest";
+import {
+ MESSAGE_BODY_MAX_DISPLAY_CHARS,
+ isStringTooLargeForInlineDisplay,
+} from "../../meshchatx/src/frontend/js/messageDisplayLimits.js";
+
+describe("messageDisplayLimits", () => {
+ it("isStringTooLargeForInlineDisplay is false at limit", () => {
+ const s = "a".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS);
+ expect(isStringTooLargeForInlineDisplay(s)).toBe(false);
+ });
+
+ it("isStringTooLargeForInlineDisplay is true above limit", () => {
+ const s = "a".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 1);
+ expect(isStringTooLargeForInlineDisplay(s)).toBe(true);
+ });
+
+ it("isStringTooLargeForInlineDisplay is false for non-strings", () => {
+ expect(isStringTooLargeForInlineDisplay(null)).toBe(false);
+ expect(isStringTooLargeForInlineDisplay(undefined)).toBe(false);
+ expect(isStringTooLargeForInlineDisplay(123)).toBe(false);
+ });
+});
diff --git a/tests/frontend/messageListVirtual.test.js b/tests/frontend/messageListVirtual.test.js
index 11773d6b..4e21252b 100644
--- a/tests/frontend/messageListVirtual.test.js
+++ b/tests/frontend/messageListVirtual.test.js
@@ -36,6 +36,18 @@ describe("messageListVirtual.js", () => {
expect(findDisplayGroupIndexForMessageHash(groups, "missing")).toBe(-1);
});
+ it("findDisplayGroupIndexForMessageHash skips date dividers", () => {
+ const groups = [
+ { type: "dateDivider", dayKey: "2026-04-26", key: "d1" },
+ { type: "single", key: "x", chatItem: { lxmf_message: { hash: "h1" } } },
+ ];
+ expect(findDisplayGroupIndexForMessageHash(groups, "h1")).toBe(1);
+ });
+
+ it("estimateGroupHeight returns modest height for date dividers", () => {
+ expect(estimateGroupHeight({ type: "dateDivider", key: "d" })).toBe(44);
+ });
+
it("MIN_VIRTUAL_DISPLAY_GROUPS is a positive threshold", () => {
expect(MIN_VIRTUAL_DISPLAY_GROUPS).toBeGreaterThan(10);
});
diff --git a/tests/frontend/messageTimestampGrouping.test.js b/tests/frontend/messageTimestampGrouping.test.js
new file mode 100644
index 00000000..1a06654e
--- /dev/null
+++ b/tests/frontend/messageTimestampGrouping.test.js
@@ -0,0 +1,111 @@
+import { describe, it, expect } from "vitest";
+import {
+ TIMESTAMP_CLUSTER_GAP_MS,
+ buildTimestampGroupedOldestFirst,
+ calendarDayKeyFromDate,
+ displayGroupIsOutbound,
+ displayGroupSortBoundsMs,
+} from "../../meshchatx/src/frontend/js/messageTimestampGrouping.js";
+
+describe("messageTimestampGrouping", () => {
+ it("calendarDayKeyFromDate uses local calendar fields", () => {
+ const d = new Date(2026, 3, 26, 23, 59);
+ expect(calendarDayKeyFromDate(d)).toBe("2026-04-26");
+ });
+
+ it("inserts a date divider when calendar day changes", () => {
+ const a = {
+ type: "single",
+ key: "a",
+ chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-25T12:00:00Z", hash: "a" } },
+ };
+ const b = {
+ type: "single",
+ key: "b",
+ chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-28T12:00:00Z", hash: "b" } },
+ };
+ const out = buildTimestampGroupedOldestFirst([a, b]);
+ const dividers = out.filter((x) => x.type === "dateDivider");
+ expect(dividers.length).toBeGreaterThanOrEqual(2);
+ expect(
+ out
+ .filter((x) => x.type === "single")
+ .map((x) => x.key)
+ .join(",")
+ ).toBe("a,b");
+ });
+
+ it("hides timestamp on middle messages of a same-side cluster within gap", () => {
+ const t0 = "2026-04-26T12:00:00Z";
+ const t1 = "2026-04-26T12:01:00Z";
+ const t2 = "2026-04-26T12:02:00Z";
+ const m0 = {
+ type: "single",
+ key: "m0",
+ chatItem: { is_outbound: true, lxmf_message: { created_at: t0, hash: "h0" } },
+ };
+ const m1 = {
+ type: "single",
+ key: "m1",
+ chatItem: { is_outbound: true, lxmf_message: { created_at: t1, hash: "h1" } },
+ };
+ const m2 = {
+ type: "single",
+ key: "m2",
+ chatItem: { is_outbound: true, lxmf_message: { created_at: t2, hash: "h2" } },
+ };
+ const out = buildTimestampGroupedOldestFirst([m0, m1, m2]).filter((x) => x.type === "single");
+ expect(out[0].showTimestamp).toBe(false);
+ expect(out[1].showTimestamp).toBe(false);
+ expect(out[2].showTimestamp).toBe(true);
+ });
+
+ it("starts a new cluster after TIMESTAMP_CLUSTER_GAP_MS", () => {
+ const t0 = "2026-04-26T12:00:00Z";
+ const t1 = new Date(new Date(t0).getTime() + TIMESTAMP_CLUSTER_GAP_MS + 60 * 1000).toISOString();
+ const a = {
+ type: "single",
+ key: "a",
+ chatItem: { is_outbound: true, lxmf_message: { created_at: t0, hash: "a" } },
+ };
+ const b = {
+ type: "single",
+ key: "b",
+ chatItem: { is_outbound: true, lxmf_message: { created_at: t1, hash: "b" } },
+ };
+ const out = buildTimestampGroupedOldestFirst([a, b]).filter((x) => x.type === "single");
+ expect(out[0].showTimestamp).toBe(true);
+ expect(out[1].showTimestamp).toBe(true);
+ });
+
+ it("with grouping disabled, omits date dividers and shows timestamp on every message", () => {
+ const a = {
+ type: "single",
+ key: "a",
+ chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-25T12:00:00Z", hash: "a" } },
+ };
+ const b = {
+ type: "single",
+ key: "b",
+ chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-28T12:00:00Z", hash: "b" } },
+ };
+ const out = buildTimestampGroupedOldestFirst([a, b], { groupingEnabled: false });
+ expect(out.filter((x) => x.type === "dateDivider")).toHaveLength(0);
+ const singles = out.filter((x) => x.type === "single");
+ expect(singles.every((x) => x.showTimestamp === true)).toBe(true);
+ });
+
+ it("displayGroupSortBoundsMs spans image group min/max", () => {
+ const g = {
+ type: "imageGroup",
+ key: "ig",
+ items: [
+ { is_outbound: true, lxmf_message: { created_at: "2026-04-26T12:00:00Z" } },
+ { is_outbound: true, lxmf_message: { created_at: "2026-04-26T12:05:00Z" } },
+ ],
+ };
+ const b = displayGroupSortBoundsMs(g);
+ expect(b.max - b.min).toBe(5 * 60 * 1000);
+ expect(displayGroupIsOutbound(g)).toBe(true);
+ });
+});
diff --git a/tests/frontend/reticulumPathfinding.test.js b/tests/frontend/reticulumPathfinding.test.js
new file mode 100644
index 00000000..21514bc7
--- /dev/null
+++ b/tests/frontend/reticulumPathfinding.test.js
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ getDestinationPath,
+ postRequestPath,
+ postDropPath,
+ runDestinationPathFinder,
+} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js";
+
+describe("reticulumPathfinding", () => {
+ it("getDestinationPath uses destination path API", async () => {
+ const api = { get: vi.fn().mockResolvedValue({ data: { path: null } }) };
+ await getDestinationPath(api, "abcd", { request: "1", timeout: 4 });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
+ params: { request: "1", timeout: 4 },
+ });
+ });
+
+ it("coerces request true to string", async () => {
+ const api = { get: vi.fn().mockResolvedValue({ data: {} }) };
+ await getDestinationPath(api, "h1", { request: true });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h1/path", {
+ params: { request: "1" },
+ });
+ });
+
+ it("coerces request false to string", async () => {
+ const api = { get: vi.fn().mockResolvedValue({ data: {} }) };
+ await getDestinationPath(api, "h2", { request: false });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h2/path", {
+ params: { request: "0" },
+ });
+ });
+
+ it("postRequestPath and postDropPath hit expected routes", async () => {
+ const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
+ await postRequestPath(api, "aaaabbbbccccddddeeeeffffaaaabbbb");
+ expect(api.post).toHaveBeenCalledWith("/api/v1/destination/aaaabbbbccccddddeeeeffffaaaabbbb/request-path");
+ await postDropPath(api, "x");
+ expect(api.post).toHaveBeenCalledWith("/api/v1/destination/x/drop-path");
+ });
+
+ it("runDestinationPathFinder quick posts request-path", async () => {
+ const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
+ const r = await runDestinationPathFinder(api, "q1", "quick");
+ expect(r.ok).toBe(true);
+ expect(api.post).toHaveBeenCalledWith("/api/v1/destination/q1/request-path");
+ });
+
+ it("runDestinationPathFinder force uses GET with wait", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { path: { hops: 1 } } }),
+ };
+ const r = await runDestinationPathFinder(api, "f1", "force", { forceTimeout: 9 });
+ expect(r.path.hops).toBe(1);
+ expect(api.get).toHaveBeenCalledWith("/api/v1/destination/f1/path", {
+ params: { request: "1", timeout: 9 },
+ });
+ });
+
+ it("runDestinationPathFinder drop_then_request drops then posts", async () => {
+ const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
+ await runDestinationPathFinder(api, "d1", "drop_then_request");
+ expect(api.post).toHaveBeenNthCalledWith(1, "/api/v1/destination/d1/drop-path");
+ expect(api.post).toHaveBeenNthCalledWith(2, "/api/v1/destination/d1/request-path");
+ });
+
+ it("runDestinationPathFinder drop_then_request continues if drop fails with handler", async () => {
+ const onDrop = vi.fn();
+ const api = {
+ post: vi.fn().mockRejectedValueOnce(new Error("no drop")).mockResolvedValue({ data: {} }),
+ };
+ await runDestinationPathFinder(api, "d2", "drop_then_request", { onDropPathError: onDrop });
+ expect(onDrop).toHaveBeenCalled();
+ expect(api.post).toHaveBeenLastCalledWith("/api/v1/destination/d2/request-path");
+ });
+
+ it("runDestinationPathFinder rejects unknown mode", async () => {
+ const api = { get: vi.fn(), post: vi.fn() };
+ await expect(runDestinationPathFinder(api, "z", "invalid")).rejects.toThrow("unknown path finder mode");
+ });
+});
diff --git a/tests/frontend/settingsSearchUtils.test.js b/tests/frontend/settingsSearchUtils.test.js
new file mode 100644
index 00000000..3735bbe4
--- /dev/null
+++ b/tests/frontend/settingsSearchUtils.test.js
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+import { describe, it, expect } from "vitest";
+import {
+ foldForSearch,
+ matchesSettingSearch,
+ normalizeSearchString,
+ tokenizeSettingsQuery,
+} from "../../meshchatx/src/frontend/js/settingsSearchUtils.js";
+
+const t = (key) => {
+ const map = {
+ "app.theme": "Theme",
+ "app.dark_theme": "Dark mode",
+ "app.stranger_protection": "Stranger protection",
+ };
+ return map[key] ?? key;
+};
+
+describe("settingsSearchUtils", () => {
+ it("normalizeSearchString trims and strips zero-width", () => {
+ expect(normalizeSearchString(" foo\u200b ")).toBe("foo");
+ expect(normalizeSearchString("\uFEFF")).toBe("");
+ });
+
+ it("tokenizeSettingsQuery splits on whitespace", () => {
+ expect(tokenizeSettingsQuery("dark theme")).toEqual(["dark", "theme"]);
+ });
+
+ it("foldForSearch removes combining marks", () => {
+ expect(foldForSearch("Café")).toBe("cafe");
+ });
+
+ it("matchesSettingSearch: empty query matches", () => {
+ expect(matchesSettingSearch(["app.theme"], t, "")).toBe(true);
+ expect(matchesSettingSearch(["app.theme"], t, " ")).toBe(true);
+ });
+
+ it("matchesSettingSearch: single token substring", () => {
+ expect(matchesSettingSearch(["app.theme", "app.dark_theme"], t, "dark")).toBe(true);
+ expect(matchesSettingSearch(["app.theme"], t, "zzz")).toBe(false);
+ });
+
+ it("matchesSettingSearch: all tokens must match (AND)", () => {
+ expect(matchesSettingSearch(["app.stranger_protection", "block"], t, "stranger block")).toBe(true);
+ expect(matchesSettingSearch(["app.stranger_protection"], t, "stranger block")).toBe(false);
+ });
+
+ it("matchesSettingSearch: resolves i18n keys with dots", () => {
+ expect(matchesSettingSearch(["app.theme"], t, "Theme")).toBe(true);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────